fix: tests

This commit is contained in:
David Nguyen
2026-09-30 15:08:40 +10:00
parent 724f5f62c5
commit 3cf00345d2
32 changed files with 446 additions and 168 deletions
@@ -113,6 +113,12 @@ export const EditorContentImageSettings = ({ formId, dataContentId }: EditorCont
onKeyDown: (event) => {
if (event.key === 'Enter' || event.key === ' ') {
event.preventDefault();
// The dropzone runs its own handler after this one unless the
// event is stopped, and that would open its own picker as well
// as the dialog's.
event.stopPropagation();
void ContentImageUploadDialog.call({ formId });
}
},
@@ -146,9 +146,20 @@ export const useEnvelopeCanvasMarquee = ({ onSelect, onEmptyClick }: UseEnvelope
window.removeEventListener('touchcancel', onWindowPointerUp);
};
/**
* Whether the current press started on an empty area of the stage.
*
* Releasing a resize handle without dragging it reaches the stage as a
* click on the stage itself. Only a press which started on the empty page
* makes an empty click, otherwise that release would deselect the item.
*/
let isPressOnEmptyStage = false;
stage.on('mousedown.marquee touchstart.marquee', (e) => {
isPressOnEmptyStage = e.target === stage;
// Do nothing if the pointer is down on a shape.
if (e.target !== stage) {
if (!isPressOnEmptyStage) {
return;
}
@@ -178,7 +189,7 @@ export const useEnvelopeCanvasMarquee = ({ onSelect, onEmptyClick }: UseEnvelope
return;
}
if (e.target === stage) {
if (e.target === stage && isPressOnEmptyStage) {
onEmptyClickRef.current();
}
});
Binary file not shown.
@@ -46,9 +46,18 @@ export const CONTENT_ALIGNMENT_PAGES = {
} as const;
/**
* The page is A4, so a square in page units needs the width scaled by this.
* The size of the PDF's pages in points, the unit contents are drawn in.
*
* Positions are percentages of the page's width and height, which are not the
* same length, so anything which has to keep its shape (a square, a rotation)
* is worked out in points.
*/
const A4_ASPECT = 210 / 297;
const PAGE_SIZE = { width: 595.92, height: 842.88 };
const toPointsX = (percent: number) => (percent / 100) * PAGE_SIZE.width;
const toPointsY = (percent: number) => (percent / 100) * PAGE_SIZE.height;
const toPercentX = (points: number) => (points / PAGE_SIZE.width) * 100;
const toPercentY = (points: number) => (points / PAGE_SIZE.height) * 100;
/**
* The grid printed on every page of the PDF, as percentages of the page.
@@ -59,7 +68,8 @@ export const CONTENT_ALIGNMENT_GRID = {
columnWidth: 28,
columnGap: 1,
rowHeight: 7,
rowGap: 1,
// Holds each box's label, which the PDF prints just above the box.
rowGap: 2,
};
const cell = (row: number, column: number, { rows = 1, columns = 1 }: { rows?: number; columns?: number } = {}) => {
@@ -73,33 +83,78 @@ const cell = (row: number, column: number, { rows = 1, columns = 1 }: { rows?: n
};
};
/**
* The box of a grid cell as printed on the PDF, for anything else placed on
* it, e.g. the seed's signature field.
*/
export const getContentAlignmentCell = cell;
type Box = ReturnType<typeof cell>;
/**
* A box half the size of a cell, placed at the cell's center.
* The box of a content which can be rotated, i.e. a shape or an image.
*/
type RotatableBox = Box & { rotation?: number };
/**
* A box half the size of a cell, rotated by `rotation` and centered in the
* cell.
*
* Contents rotate about their top left corner (see `ZContentRotationSchema`),
* so a rotated box placed at a cell's top left would sweep outside the cell.
* Starting from the center keeps every rotation within the cell.
* so that corner is placed wherever the rotation needs it for the box's center
* to land on the cell's center. Throws if the rotated box would not fit in the
* cell, since it would then reach into the neighbouring cells.
*/
const halfCell = (row: number, column: number, options: { rows?: number; columns?: number } = {}) => {
const rotatedHalfCell = (
row: number,
column: number,
rotation: number,
options: { rows?: number; columns?: number } = {},
): RotatableBox => {
const box = cell(row, column, options);
const width = box.width / 2;
const height = box.height / 2;
// Half the box's width and height, in points.
const halfWidth = toPointsX(width) / 2;
const halfHeight = toPointsY(height) / 2;
const radians = (rotation * Math.PI) / 180;
const cos = Math.cos(radians);
const sin = Math.sin(radians);
// Where the box's center ends up, relative to its top left corner.
const centerX = halfWidth * cos - halfHeight * sin;
const centerY = halfWidth * sin + halfHeight * cos;
// How far the rotated box reaches from its center along each axis.
const reachX = Math.abs(halfWidth * cos) + Math.abs(halfHeight * sin);
const reachY = Math.abs(halfWidth * sin) + Math.abs(halfHeight * cos);
const cellWidth = toPointsX(box.width);
const cellHeight = toPointsY(box.height);
if (reachX * 2 > cellWidth || reachY * 2 > cellHeight) {
throw new Error(`A half size box rotated by ${rotation} degrees does not fit in cell ${row}, ${column}`);
}
return {
positionX: box.positionX + box.width / 2,
positionY: box.positionY + box.height / 2,
width: box.width / 2,
height: box.height / 2,
positionX: box.positionX + toPercentX(cellWidth / 2 - centerX),
positionY: box.positionY + toPercentY(cellHeight / 2 - centerY),
width,
height,
rotation,
};
};
/**
* A square box in page units, as tall as the given number of rows.
* A square box as tall as the given number of rows.
*/
const square = (row: number, column: number, rows: number) => {
const square = (row: number, column: number, rows: number): Box => {
const box = cell(row, column, { rows });
return { ...box, width: box.height * A4_ASPECT };
return { ...box, width: toPercentX(toPointsY(box.height)) };
};
// --- Builders ---
@@ -108,22 +163,14 @@ type Overrides<T> = Partial<Omit<T, 'type'>>;
const text = (box: Box, meta: Overrides<TContentTextMeta> & { text: string }): ContentTestData => ({
page: CONTENT_ALIGNMENT_PAGES.text,
contentMeta: { type: EnvelopeContentType.TEXT, page: CONTENT_ALIGNMENT_PAGES.text, rotation: 0, ...box, ...meta },
contentMeta: { type: EnvelopeContentType.TEXT, page: CONTENT_ALIGNMENT_PAGES.text, ...box, ...meta },
});
type LinePoints = { x1: number; y1: number; x2: number; y2: number };
const horizontal = (box: Box): LinePoints => ({
x1: box.positionX,
x2: box.positionX + box.width,
y1: box.positionY + box.height / 2,
y2: box.positionY + box.height / 2,
});
type LineMeta = { strokeWidth: number; strokeColor?: string; strokeStyle?: 'solid' | 'dashed' | 'dotted' };
const line = (
points: LinePoints,
meta: { strokeWidth: number; strokeColor?: string; strokeStyle?: 'solid' | 'dashed' | 'dotted' },
): ContentTestData => ({
const line = (points: LinePoints, meta: LineMeta): ContentTestData => ({
page: CONTENT_ALIGNMENT_PAGES.line,
contentMeta: {
type: EnvelopeContentType.LINE,
@@ -135,7 +182,21 @@ const line = (
},
});
const rectangle = (box: Box, meta: Overrides<TContentShapeMeta> = {}): ContentTestData => ({
/**
* A horizontal line across the middle of a box.
*
* Round line ends reach past the line's points by half its width, so the
* points are pulled in by that much to keep the whole line within its box.
* Otherwise thick lines in neighbouring columns join across the gap.
*/
const horizontalLine = (box: Box, meta: LineMeta): ContentTestData => {
const inset = toPercentX(meta.strokeWidth / 2);
const y = box.positionY + box.height / 2;
return line({ x1: box.positionX + inset, y1: y, x2: box.positionX + box.width - inset, y2: y }, meta);
};
const rectangle = (box: RotatableBox, meta: Overrides<TContentShapeMeta> = {}): ContentTestData => ({
page: CONTENT_ALIGNMENT_PAGES.shape,
contentMeta: {
type: EnvelopeContentType.SHAPE,
@@ -155,7 +216,6 @@ const highlight = (box: Box, meta: Overrides<TContentHighlightMeta> = {}): Conte
contentMeta: {
type: EnvelopeContentType.HIGHLIGHT,
page: CONTENT_ALIGNMENT_PAGES.highlight,
rotation: 0,
color: '#ffe600',
fillOpacity: 0.4,
...box,
@@ -163,7 +223,7 @@ const highlight = (box: Box, meta: Overrides<TContentHighlightMeta> = {}): Conte
},
});
const image = (box: Box, meta: Overrides<TContentImageMeta> = {}): ContentTestData => ({
const image = (box: RotatableBox, meta: Overrides<TContentImageMeta> = {}): ContentTestData => ({
page: CONTENT_ALIGNMENT_PAGES.image,
image: 'logo',
contentMeta: { type: EnvelopeContentType.IMAGE, page: CONTENT_ALIGNMENT_PAGES.image, rotation: 0, ...box, ...meta },
@@ -184,30 +244,26 @@ const TEXT_CONTENTS: ContentTestData[] = [
text(cell(3, 1), { text: 'Size 14', fontSize: 14, color: '#d00000' }),
text(cell(3, 2), { text: 'Size 24 cropped to the box', fontSize: 24 }),
text(cell(4, 0), { text: 'Two lines of text wrap here', lineHeight: 1.6 }),
// Long enough to wrap, so the line height shows in the gap between the lines.
text(cell(4, 0), { text: 'Two lines of text, wrapped and spaced apart by the line height', lineHeight: 1.6 }),
text(cell(4, 1), { text: 'Spaced', letterSpacing: 3 }),
text(cell(4, 2, { rows: 2 }), {
text: 'A longer paragraph of text which wraps onto several lines within its box, centered both ways.',
textAlign: 'center',
verticalAlign: 'middle',
}),
// Rotation pivots on the box's top left corner, so a 90 degree rotation
// hangs down the left edge of its cell. Inset it so it stays inside.
text(cell(6, 0, { rows: 2 }), { text: 'Rotated 15', rotation: 15 }),
text({ ...cell(6, 1, { rows: 2 }), positionX: cell(6, 1).positionX + 8 }, { text: 'Rotated 90', rotation: 90 }),
];
// --- Page 2: Lines ---
const LINE_CONTENTS: ContentTestData[] = [
line(horizontal(cell(0, 0)), { strokeWidth: 1, strokeStyle: 'solid' }),
line(horizontal(cell(0, 1)), { strokeWidth: 2, strokeStyle: 'dashed' }),
line(horizontal(cell(0, 2)), { strokeWidth: 3, strokeStyle: 'dotted' }),
horizontalLine(cell(0, 0), { strokeWidth: 1, strokeStyle: 'solid' }),
horizontalLine(cell(0, 1), { strokeWidth: 2, strokeStyle: 'dashed' }),
horizontalLine(cell(0, 2), { strokeWidth: 3, strokeStyle: 'dotted' }),
line(horizontal(cell(1, 0)), { strokeWidth: 0.5 }),
line(horizontal(cell(1, 1)), { strokeWidth: 5 }),
line(horizontal(cell(1, 2)), { strokeWidth: 10 }),
horizontalLine(cell(1, 0), { strokeWidth: 0.5 }),
horizontalLine(cell(1, 1), { strokeWidth: 5 }),
horizontalLine(cell(1, 2), { strokeWidth: 10 }),
// Diagonals across the full width, corner to corner.
...(() => {
@@ -313,11 +369,10 @@ const SHAPE_CONTENTS: ContentTestData[] = [
rectangle(square(2, 0, 2), { strokeWidth: 2 }),
rectangle(cell(2, 1, { columns: 2 }), { strokeWidth: 1, fillColor: '#e5e7eb', fillOpacity: 1 }),
// Rotation pivots on the box's top left corner. Half sized boxes placed
// towards the right of their cells keep the swept shape within the cell.
rectangle(halfCell(4, 0, { rows: 2 }), { strokeWidth: 2, strokeColor: '#d00000', rotation: 15 }),
rectangle(halfCell(4, 1, { rows: 2 }), { strokeWidth: 2, strokeColor: '#00aa55', rotation: 45 }),
rectangle(halfCell(4, 2, { rows: 2 }), { strokeWidth: 2, strokeColor: '#0055aa', rotation: 90 }),
// Each rotated shape is centered in its cell, clear of the stacking row.
rectangle(rotatedHalfCell(4, 0, 15, { rows: 2 }), { strokeWidth: 2, strokeColor: '#d00000' }),
rectangle(rotatedHalfCell(4, 1, 45, { rows: 2 }), { strokeWidth: 2, strokeColor: '#00aa55' }),
rectangle(rotatedHalfCell(4, 2, 90, { rows: 2 }), { strokeWidth: 2, strokeColor: '#0055aa' }),
// Stacking. Each cell holds two overlapping filled rectangles: the first
// (top left) and the second (offset to the bottom right). The rectangles
@@ -342,9 +397,6 @@ const HIGHLIGHT_CONTENTS: ContentTestData[] = [
highlight(cell(1, 2), { fillOpacity: 1 }),
highlight(cell(2, 0, { columns: 3 })),
highlight(halfCell(3, 0, { rows: 2 }), { rotation: 15 }),
highlight(halfCell(3, 1, { rows: 2 }), { rotation: 45 }),
];
// --- Page 5: Images ---
@@ -357,14 +409,13 @@ const IMAGE_CONTENTS: ContentTestData[] = [
image(cell(3, 0, { columns: 3, rows: 2 })),
// Rotation pivots on the box's top left corner. Half sized boxes placed
// towards the right of their cells keep the swept image within the cell.
image(halfCell(5, 0, { rows: 2 }), { rotation: 15 }),
image(halfCell(5, 1, { rows: 2 }), { rotation: 45 }),
image(halfCell(5, 2, { rows: 2 }), { rotation: 90 }),
// Each rotated image is centered in its cell, clear of its neighbours.
image(rotatedHalfCell(5, 0, 15, { rows: 2 })),
image(rotatedHalfCell(5, 1, 45, { rows: 2 })),
image(rotatedHalfCell(5, 2, 90, { rows: 2 })),
image(halfCell(7, 0, { rows: 2 }), { rotation: 180 }),
image(halfCell(7, 1, { rows: 2 }), { rotation: 270 }),
image(rotatedHalfCell(7, 0, 180, { rows: 2 })),
image(rotatedHalfCell(7, 1, 270, { rows: 2 })),
];
export const CONTENT_ALIGNMENT_TEST_CONTENTS: ContentTestData[] = [
@@ -25,10 +25,11 @@ test.describe.configure({
* transform.
*/
const textMeta = (overrides: Partial<TEnvelopeContentMetaInput> = {}): TEnvelopeContentMetaInput => ({
type TextMetaInput = Extract<TEnvelopeContentMetaInput, { type: EnvelopeContentType.TEXT }>;
const textMeta = (overrides: Partial<Omit<TextMetaInput, 'type'>> = {}): TEnvelopeContentMetaInput => ({
type: EnvelopeContentType.TEXT,
page: 1,
rotation: 0,
positionX: 10,
positionY: 10,
width: 20,
@@ -67,7 +68,7 @@ const setupEnvelope = async (page: Page, type: 'document' | 'template' = 'docume
const envelopeRecord =
type === 'document'
? await seedBlankDocument(user, team.id, { internalVersion: 2 })
: await seedBlankTemplate(user, team.id, { internalVersion: 2 });
: await seedBlankTemplate(user, team.id, { createTemplateOptions: { internalVersion: 2 } });
await apiSignin({ page, email: user.email, redirectPath: `/t/${team.url}/documents` });
@@ -29,7 +29,6 @@ test.describe.configure({
const textMeta = (index: number, overrides: { zIndex?: number } = {}): TEnvelopeContentMetaInput => ({
type: EnvelopeContentType.TEXT,
page: 1,
rotation: 0,
positionX: 10,
positionY: 10,
width: 20,
@@ -179,7 +179,6 @@ const buildAndDistributeEnvelopeWithContents = async ({
: {
type: EnvelopeContentType.TEXT,
page: 1,
rotation: 0,
zIndex: 0,
positionX: 10,
positionY: 10,
@@ -16,7 +16,7 @@ import type {
} from '@documenso/trpc/server/envelope-router/create-envelope.types';
import type { TGetEnvelopeResponse } from '@documenso/trpc/server/envelope-router/get-envelope.types';
import { type APIRequestContext, expect, test } from '@playwright/test';
import { EnvelopeType, type Team } from '@prisma/client';
import { EnvelopeType, FieldType, RecipientRole, type Team } from '@prisma/client';
import { createGifLabelledAsPng, createImageFile, type TestImageFile } from '../../fixtures/contents';
@@ -378,6 +378,62 @@ test.describe('Create envelope with contents', () => {
});
}
// A file is picked by its position counted from 0, so any other number is
// refused rather than read as some other file (-1 used to mean the last).
for (const { identifier, description } of [
{ identifier: -1, description: 'a negative file index' },
{ identifier: 0.5, description: 'a fractional file index' },
]) {
test(`rejects a content on ${description}`, async ({ request }) => {
const externalId = `e2e-contents-bad-index-${nanoid()}`;
const res = await createEnvelopeWithContents({
request,
token,
payload: { externalId },
contents: [{ identifier, contentMeta: textMeta('Bad index') }],
});
expect(res.status()).toBe(400);
expect(await prisma.envelope.count({ where: { externalId } })).toBe(0);
});
test(`rejects a field on ${description}`, async ({ request }) => {
const externalId = `e2e-fields-bad-index-${nanoid()}`;
const res = await createEnvelopeWithContents({
request,
token,
payload: {
externalId,
recipients: [
{
email: `signer-${nanoid()}@test.documenso.com`,
name: 'Signer',
role: RecipientRole.SIGNER,
fields: [
{
type: FieldType.SIGNATURE,
identifier,
page: 1,
positionX: 10,
positionY: 10,
width: 10,
height: 5,
fieldMeta: { type: 'signature', overflow: 'crop' },
},
],
},
],
},
contents: [],
});
expect(res.status()).toBe(400);
expect(await prisma.envelope.count({ where: { externalId } })).toBe(0);
});
}
test('rejects an image which is not really a PNG, JPEG or WebP', async ({ request }) => {
const externalId = `e2e-contents-format-${nanoid()}`;
const image = createGifLabelledAsPng(`format-${nanoid()}.png`);
@@ -0,0 +1,107 @@
import { expect, type Page, test } from '@playwright/test';
import type Konva from 'konva';
import {
getPageCanvas,
interactWithCanvasPastActionBar,
placeContentOnPdf,
selectEditorTab,
} from '../fixtures/contents';
import { clickAddMyselfButton, clickEnvelopeEditorStep, openDocumentEnvelopeEditor } from '../fixtures/envelope-editor';
import { getKonvaTransformerNodeCountForPage } from '../fixtures/konva';
/**
* A selected item's resize handles reach a little into the item, so they are
* easy to grab. Pressing and releasing one without dragging used to count as
* a click on the empty page, which deselected the item.
*/
const getCanvasBox = async (page: Page) => {
const box = await getPageCanvas(page).boundingBox();
if (!box) {
throw new Error('The page canvas is not rendered');
}
return box;
};
/**
* Press and release the selection's bottom middle resize handle without
* moving, just inside the selected item.
*/
const clickResizeHandle = async (page: Page) => {
const handle = await page.evaluate(() => {
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
const konva: typeof Konva = (window as unknown as { Konva: typeof Konva }).Konva;
const anchor = konva.stages.find((stage) => stage.attrs.id === 'page-1')?.findOne('.bottom-center');
if (!anchor) {
throw new Error('The selection has no bottom middle resize handle');
}
const rect = anchor.getClientRect();
return { x: rect.x + rect.width / 2, y: rect.y + rect.height / 2 };
});
const canvas = await getCanvasBox(page);
await interactWithCanvasPastActionBar(page, async () => {
await page.mouse.move(canvas.x + handle.x + 10, canvas.y + handle.y - 5);
await page.mouse.down();
await page.mouse.up();
});
};
const expectSelectionKept = async (page: Page) => {
// Nothing should change, so give anything which would deselect a moment.
await page.waitForTimeout(500);
expect(await getKonvaTransformerNodeCountForPage(page, 1)).toBe(1);
};
/**
* An empty area of the page still deselects.
*/
const expectEmptyClickDeselects = async (page: Page) => {
const canvas = await getCanvasBox(page);
await page.mouse.click(canvas.x + 400, canvas.y + 700);
await expect.poll(async () => await getKonvaTransformerNodeCountForPage(page, 1)).toBe(0);
};
test('clicking a content resize handle without dragging keeps the content selected', async ({ page }) => {
await openDocumentEnvelopeEditor(page);
await clickEnvelopeEditorStep(page, 'addFields');
await expect(getPageCanvas(page)).toBeVisible();
await selectEditorTab(page, 'Contents');
// A newly placed content is selected.
await placeContentOnPdf(page, 'Text', { x: 160, y: 160 });
await expect.poll(async () => await getKonvaTransformerNodeCountForPage(page, 1)).toBe(1);
await clickResizeHandle(page);
await expectSelectionKept(page);
await expectEmptyClickDeselects(page);
});
test('clicking a field resize handle without dragging keeps the field selected', async ({ page }) => {
await openDocumentEnvelopeEditor(page);
await clickAddMyselfButton(page);
await clickEnvelopeEditorStep(page, 'addFields');
await expect(getPageCanvas(page)).toBeVisible();
// A newly placed field is selected.
await page.getByRole('button', { name: 'Signature', exact: true }).click();
await getPageCanvas(page).click({ position: { x: 160, y: 160 } });
await expect.poll(async () => await getKonvaTransformerNodeCountForPage(page, 1)).toBe(1);
await clickResizeHandle(page);
await expectSelectionKept(page);
await expectEmptyClickDeselects(page);
});
@@ -71,7 +71,9 @@ test('a selected content survives zooming', async ({ page }) => {
await placeContentOnPdf(root, 'Text', { x: 160, y: 160 });
await selectContentOnCanvas(root, { x: 170, y: 170 });
// Clicked in the middle, where it was placed. Placing already selects it,
// and near its edges a click lands on the handles of that selection.
await selectContentOnCanvas(root, { x: 160, y: 160 });
await expect.poll(() => getKonvaTransformerNodeCountForPage(root, 1)).toBe(1);
await expect(root.getByTestId('envelope-canvas-action-bar')).toBeVisible();
@@ -2,7 +2,7 @@ import { APP_CONTENT_IMAGE_UPLOAD_SIZE_LIMIT } from '@documenso/lib/constants/en
import { megabytesToBytes } from '@documenso/lib/universal/unit-convertions';
import { prisma } from '@documenso/prisma';
import { createCanvas } from '@napi-rs/canvas';
import { expect, type Page, test } from '@playwright/test';
import { expect, type FileChooser, type Page, test } from '@playwright/test';
import {
getContentActionButton,
@@ -52,6 +52,15 @@ const uploadThroughDialog = async (page: Page, buffer: Buffer) => {
await (await fileChooser).setFiles({ name: 'logo.png', mimeType: 'image/png', buffer });
};
/**
* The upload dialog, including while it is hidden.
*
* While the native picker is open the dialog hides its content and shows only
* its overlay. Role locators skip hidden elements unless told otherwise, so
* without `includeHidden` an open dialog would look closed.
*/
const getUploadDialog = (page: Page) => page.getByRole('dialog', { includeHidden: true });
test('a dropped file which cannot be used shows why', async ({ page }) => {
await openContentsEditor(page);
@@ -147,53 +156,106 @@ test('the upload lands on the content it was opened for', async ({ page }) => {
});
/**
* Dismissing the native picker leaves the dialog open on its "Choose image"
* button. Pressing that must reopen the picker even though the dialog's
* state has not changed.
* Dismissing the native picker closes the dialog, so picking again means
* opening it again. That must start a fresh picker rather than reuse the
* dismissed dialog's state.
*/
test('the picker can be reopened from the dialog after being dismissed', async ({ page }) => {
test('the picker can be reopened after being dismissed', async ({ page }) => {
const surface = await openContentsEditor(page);
await placeContentOnPdf(page, 'Image', { x: 200, y: 200 });
await waitForContentsAutosave(surface);
// Open the picker and leave it unanswered: the dialog stays on "picking".
const dialog = getUploadDialog(page);
// Nothing matches before the dialog opens, so what matches below is the
// upload dialog rather than some other hidden dialog.
await expect(dialog).toHaveCount(0);
const firstChooser = page.waitForEvent('filechooser');
await getContentActionButton(page, 'Upload image').click();
await firstChooser;
await expect(page.getByRole('button', { name: 'Choose image' })).toBeVisible();
await expect(dialog).toHaveCount(1);
// Playwright cannot dismiss a native picker, but the browser reports a
// dismissal as a `cancel` event on the input.
await page.locator('input[id^="content-image-input-"]').dispatchEvent('cancel');
await expect(dialog).toHaveCount(0);
// Pressing the dialog's button opens a fresh picker.
const secondChooser = page.waitForEvent('filechooser');
await page.getByRole('button', { name: 'Choose image' }).click();
await getContentActionButton(page, 'Upload image').click();
await (await secondChooser).setFiles({ name: 'logo.png', mimeType: 'image/png', buffer: await createPng() });
await expect(getContentActionButton(page, 'Replace image')).toBeVisible();
});
/**
* The drop area opens the upload dialog from the keyboard too. Only the
* dialog's picker may open: the dropzone used to open its own picker as well,
* so a keyboard user got two.
*/
for (const key of ['Enter', 'Space']) {
test(`pressing ${key} on the drop area opens a single file picker`, async ({ page }) => {
await openContentsEditor(page);
await placeContentOnPdf(page, 'Image', { x: 200, y: 200 });
const choosers: FileChooser[] = [];
page.on('filechooser', (chooser) => choosers.push(chooser));
await page.locator('div[role="button"]', { hasText: 'Click to upload or drag and drop' }).focus();
await page.keyboard.press(key);
// Give a second picker time to open, as it used to.
await expect.poll(() => choosers.length).toBeGreaterThan(0);
await page.waitForTimeout(1000);
expect(choosers).toHaveLength(1);
// The one picker is the upload dialog's, so the file goes through it.
expect(await choosers[0].element().getAttribute('id')).toMatch(/^content-image-input-/);
await choosers[0].setFiles({ name: 'logo.png', mimeType: 'image/png', buffer: await createPng() });
await expect(getContentActionButton(page, 'Replace image')).toBeVisible();
});
}
test('cancelling the picker closes the dialog', async ({ page }) => {
const surface = await openContentsEditor(page);
await placeContentOnPdf(page, 'Image', { x: 200, y: 200 });
await waitForContentsAutosave(surface);
const dialog = getUploadDialog(page);
// Nothing matches before the dialog opens, so what matches below is the
// upload dialog rather than some other hidden dialog.
await expect(dialog).toHaveCount(0);
const chooser = page.waitForEvent('filechooser');
await getContentActionButton(page, 'Upload image').click();
await chooser;
await expect(page.getByRole('dialog')).toBeVisible();
// While the picker is open only the dialog's overlay shows, which still
// blocks the editor. A trial click checks that without clicking.
await expect(dialog).toHaveCount(1);
await expect(getPageCanvas(page).click({ position: { x: 50, y: 50 }, trial: true, timeout: 1500 })).rejects.toThrow();
// Playwright cannot dismiss a native picker, but the browser reports a
// dismissal as a `cancel` event on the input, which is what the dialog
// listens for.
await page.locator('input[id^="content-image-input-"]').dispatchEvent('cancel');
await expect(page.getByRole('dialog')).toHaveCount(0);
// The dialog is gone and the editor can be used again.
await expect(dialog).toHaveCount(0);
await getPageCanvas(page).click({ position: { x: 50, y: 50 }, trial: true });
await expect(getContentActionButton(page, 'Upload image')).toBeVisible();
});
@@ -127,7 +127,7 @@ const runUploadReplaceRemoveFlow = async (surface: TEnvelopeEditorSurface) => {
await uploadImage(root, wide);
await waitForUploadToLand(root);
await expect(root.getByText('wide-logo.png')).toBeVisible();
// The settings panel shows the image's size and dimensions.
await expect(root.getByText('400 * 200')).toBeVisible();
const [withWide] = await getContentGroupsForPage(root);
@@ -157,7 +157,7 @@ const runUploadReplaceRemoveFlow = async (surface: TEnvelopeEditorSurface) => {
await uploadImage(root, tall);
await waitForUploadToLand(root);
await expect(root.getByText('tall-logo.jpg')).toBeVisible();
await expect(root.getByText('100 * 300')).toBeVisible();
const [withTall] = await getContentGroupsForPage(root);
@@ -224,7 +224,7 @@ const runActionBarUploadFlow = async (surface: TEnvelopeEditorSurface) => {
await (await fileChooser).setFiles({ name: huge.name, mimeType: huge.mimeType, buffer: huge.buffer });
await expect(root.getByText('huge-logo.png')).toBeVisible();
await expect(root.getByText('2000 * 2000')).toBeVisible();
await expect(getContentActionButton(root, 'Replace image')).toBeVisible();
const pageSize = await getPageSize(root);
@@ -333,7 +333,7 @@ const runSendGuardFlow = async (surface: TEnvelopeEditorSurface) => {
await uploadImage(root, logo);
await waitForUploadToLand(root);
await expect(root.getByText('logo.png')).toBeVisible();
await expect(root.getByText('120 * 60')).toBeVisible();
await waitForContentsAutosave(surface);
@@ -122,7 +122,12 @@ test('duplicate is hidden at the limit and existing contents stay removable', as
const [content] = await getContentGroupsForPage(root);
const { scale } = await getPageSize(root);
await selectContentOnCanvas(root, { x: (content.rect.x + 4) * scale, y: (content.rect.y + 4) * scale });
// Clicked in the middle. Placing already selects it, and near its edges a
// click lands on the handles of that selection.
await selectContentOnCanvas(root, {
x: (content.rect.x + content.rect.width / 2) * scale,
y: (content.rect.y + content.rect.height / 2) * scale,
});
// Duplicating would add a content, so it is gone; removing is still offered.
await expect(getContentActionButton(root, 'Duplicate')).toHaveCount(0);
@@ -31,7 +31,6 @@ const seedTiedContents = async (envelopeId: string) => {
contentMeta: ZEnvelopeContentMetaSchema.parse({
type: EnvelopeContentType.TEXT,
page: 1,
rotation: 0,
positionX,
positionY: 20,
width: 10,
@@ -48,7 +48,6 @@ const seedContent = async (envelopeId: string, envelopeItemId: string, page: num
contentMeta: ZEnvelopeContentMetaSchema.parse({
type: EnvelopeContentType.TEXT,
page,
rotation: 0,
positionX: 10,
positionY: 10,
width: 20,
@@ -71,6 +70,10 @@ test('does not recreate contents removed by a pdf replacement', async ({ page })
const surface = await openDocumentEnvelopeEditor(page);
const { root, envelopeId } = surface;
if (!envelopeId) {
throw new Error('Expected the document editor surface to have an envelopeId');
}
// A three page PDF so a content can live on a page the replacement drops.
await replacePdf(root, multiPagePdfBuffer, 'multi-page.pdf');
@@ -25,7 +25,6 @@ const seedStaticContents = async (envelopeId: string, envelopeItemId: string) =>
contentMeta: ZEnvelopeContentMetaSchema.parse({
type: EnvelopeContentType.TEXT,
page: 1,
rotation: 0,
zIndex: 0,
positionX: 10,
positionY: 10,
@@ -2,7 +2,7 @@ import { createDataContentImage } from '@documenso/lib/server-only/data-content/
import { EnvelopeContentType, ZEnvelopeContentMetaSchema } from '@documenso/lib/types/envelope-content-meta';
import { generateDatabaseId } from '@documenso/lib/universal/id';
import { prisma } from '@documenso/prisma';
import { seedPendingDocument } from '@documenso/prisma/seed/documents';
import { seedBlankDocument, seedPendingDocument } from '@documenso/prisma/seed/documents';
import { seedUser } from '@documenso/prisma/seed/users';
import { createCanvas } from '@napi-rs/canvas';
import { expect, test } from '@playwright/test';
@@ -13,6 +13,10 @@ import { apiSignin } from '../fixtures/authentication';
* A page and everything drawn on it appear together. The page image is held
* back until the content images are loaded too, so a page is never shown in a
* half drawn state.
*
* Contents are only drawn in the browser while the document is a draft. Once
* it is sent they are part of the PDF, so the signing page loads no content
* images at all.
*/
const IMAGE_LOAD_MS = 4000;
@@ -20,16 +24,13 @@ const IMAGE_LOAD_MS = 4000;
test('a page waiting on its content images shows a loader', async ({ page }) => {
const { user, team } = await seedUser();
const document = await seedPendingDocument(user, team.id, [user], { internalVersion: 2 });
const document = await seedBlankDocument(user, team.id, { internalVersion: 2 });
const envelope = await prisma.envelope.findUniqueOrThrow({
where: { id: document.id },
include: { envelopeItems: true, recipients: true },
include: { envelopeItems: true },
});
// The seeded fields carry no `fieldMeta`, which the v2 signer rejects.
await prisma.field.deleteMany({ where: { envelopeId: envelope.id } });
const png = await createCanvas(60, 30).encode('png');
const dataContent = await createDataContentImage({
@@ -61,7 +62,11 @@ test('a page waiting on its content images shows a loader', async ({ page }) =>
await route.continue();
});
await apiSignin({ page, email: user.email, redirectPath: `/sign/${envelope.recipients[0].token}` });
await apiSignin({
page,
email: user.email,
redirectPath: `/t/${team.url}/documents/${envelope.id}/edit?step=addFields`,
});
const pageLoader = page.getByTestId('page-loader').first();
const pageImage = page.locator('img[alt=""]').first();
@@ -21,7 +21,6 @@ const multiPagePdf = fs.readFileSync(path.join(__dirname, '../../../../assets/fi
const textMetaOnPage = (page: number) => ({
type: EnvelopeContentType.TEXT,
page,
rotation: 0,
positionX: 10,
positionY: 10,
width: 20,
@@ -95,43 +94,6 @@ test('replacing a PDF drops contents which fall beyond the new page count', asyn
expect(contentMeta.page).toBe(1);
});
test('replacing a PDF keeps a content which has no page of its own', async () => {
const { user, team } = await seedUser();
const document = await seedBlankDocument(user, team.id, { internalVersion: 2 });
await replacePdf(document.id, multiPagePdf, 'multi-page.pdf');
const envelope = await prisma.envelope.findUniqueOrThrow({
where: { id: document.id },
include: { envelopeItems: true },
});
// A content without a page belongs to the first page, so it always fits.
await prisma.envelopeContent.create({
data: {
id: generateDatabaseId('envelope_content'),
envelopeId: envelope.id,
envelopeItemId: envelope.envelopeItems[0].id,
contentMeta: ZEnvelopeContentMetaSchema.parse({
type: EnvelopeContentType.TEXT,
rotation: 0,
positionX: 10,
positionY: 10,
width: 20,
height: 6,
text: 'No page',
}),
},
});
await replacePdf(document.id, singlePagePdf, 'single-page.pdf');
const contents = await prisma.envelopeContent.findMany({ where: { envelopeId: envelope.id } });
expect(contents).toHaveLength(1);
});
test('replacing a PDF keeps contents which still fit', async () => {
const { user, team } = await seedUser();
+4 -1
View File
@@ -251,10 +251,13 @@ export const uploadImage = async (root: Page, file: TestImageFile) => {
/**
* Wait for a successful upload's dialog to close.
*
* Hidden dialogs count too: while its picker is open the dialog hides its
* content, and a dialog stuck like that has not finished.
*/
export const waitForUploadToLand = async (root: Page) => {
await expect(root.getByTestId('content-image-uploading')).toHaveCount(0);
await expect(root.getByRole('dialog')).toHaveCount(0);
await expect(root.getByRole('dialog', { includeHidden: true })).toHaveCount(0);
};
/**
@@ -35,7 +35,7 @@ const createPng = async () => {
const seedTemplateWithImageContent = async (page: Page) => {
const { user, team } = await seedUser();
const template = await seedBlankTemplate(user, team.id, { internalVersion: 2 });
const template = await seedBlankTemplate(user, team.id, { createTemplateOptions: { internalVersion: 2 } });
const envelope = await prisma.envelope.findFirstOrThrow({
where: { id: template.id },
+16 -13
View File
@@ -1,9 +1,12 @@
import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app';
import { prisma } from '@documenso/prisma';
import { seedUser } from '@documenso/prisma/seed/users';
import { expect, type Page, test } from '@playwright/test';
import { apiSignin, apiSignout, checkSessionValid } from '../fixtures/authentication';
const WEBAPP_BASE_URL = NEXT_PUBLIC_WEBAPP_URL();
test.use({ storageState: { cookies: [], origins: [] } });
test('[USER] can reset password via forgot password', async ({ page }: { page: Page }) => {
@@ -14,9 +17,9 @@ test('[USER] can reset password via forgot password', async ({ page }: { page: P
password: oldPassword,
});
await page.goto('http://localhost:3000/signin');
await page.goto(`${WEBAPP_BASE_URL}/signin`);
await page.getByRole('link', { name: 'Forgot your password?' }).click();
await expect(page).toHaveURL('http://localhost:3000/forgot-password');
await expect(page).toHaveURL(`${WEBAPP_BASE_URL}/forgot-password`);
await page.getByRole('textbox', { name: 'Email' }).click();
await page.getByRole('textbox', { name: 'Email' }).fill(user.email);
@@ -37,7 +40,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(`${WEBAPP_BASE_URL}/reset-password/${foundToken.token}`);
// Assert that password cannot be same as old password.
await page.getByLabel('Password', { exact: true }).fill(oldPassword);
@@ -131,9 +134,9 @@ test('[USER] password reset invalidates all sessions', async ({ page }: { page:
await page.context().clearCookies();
await page.goto('http://localhost:3000/signin');
await page.goto(`${WEBAPP_BASE_URL}/signin`);
await page.getByRole('link', { name: 'Forgot your password?' }).click();
await expect(page).toHaveURL('http://localhost:3000/forgot-password');
await expect(page).toHaveURL(`${WEBAPP_BASE_URL}/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', {
@@ -144,7 +147,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(`${WEBAPP_BASE_URL}/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();
@@ -152,8 +155,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(`${WEBAPP_BASE_URL}/settings/profile`);
await expect(page).toHaveURL(`${WEBAPP_BASE_URL}/signin`);
expect(await checkSessionValid(page)).toBe(false);
@@ -197,7 +200,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(`${WEBAPP_BASE_URL}/settings/security`);
await page.getByLabel('Current password').fill(oldPassword);
await page.getByLabel('New password').fill(newPassword);
await page.getByLabel('Repeat password').fill(newPassword);
@@ -208,13 +211,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(`${WEBAPP_BASE_URL}/settings/profile`);
await expect(page).toHaveURL(`${WEBAPP_BASE_URL}/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(`${WEBAPP_BASE_URL}/settings/security`);
await expect(page).toHaveURL(`${WEBAPP_BASE_URL}/settings/security`);
expect(await checkSessionValid(page)).toBe(true);
});
Binary file not shown.

After

Width:  |  Height:  |  Size: 217 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 144 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 134 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 100 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 301 KiB

@@ -40,7 +40,7 @@ const rectangle = {
describe('generatePartialSignedPdf', () => {
it('produces a valid PDF when there is nothing to draw', async () => {
const output = await generatePartialSignedPdf({ pdfData: examplePdf });
const output = await generatePartialSignedPdf({ pdfData: examplePdf, fields: [] });
const pdf = await PDF.load(output);
@@ -48,7 +48,7 @@ describe('generatePartialSignedPdf', () => {
});
it('draws the contents onto the page', async () => {
const output = await generatePartialSignedPdf({ pdfData: examplePdf, contents: [rectangle] });
const output = await generatePartialSignedPdf({ pdfData: examplePdf, fields: [], contents: [rectangle] });
// The overlay is added as a new XObject, so the file must grow and must
// still be a valid single page PDF.
@@ -8,6 +8,7 @@ import sharp from 'sharp';
import { beforeAll, describe, expect, it } from 'vitest';
import { EnvelopeContentShapeType, EnvelopeContentType } from '../../types/envelope-content-meta';
import type { ContentImageSource } from '../../universal/content-renderer/content-renderer';
import { insertPageOverlays } from './insert-page-overlay';
/**
@@ -38,6 +39,7 @@ const RED_RECTANGLE_OVERLAY: Overlay = {
zIndex: 0,
rotation: 0,
...RECT,
fillColor: null,
fillOpacity: 0,
strokeWidth: STROKE_WIDTH,
strokeColor: '#ff0000',
@@ -78,7 +80,9 @@ const createBlueImageOverlay = async (): Promise<Overlay> => {
dataContentId: 'data_blue',
},
],
images: new Map([['data_blue', new SkiaImage(Buffer.from(png))]]),
// On the server a skia image stands in for a browser image, passed the
// same way `loadContentImages` passes it.
images: new Map([['data_blue', new SkiaImage(Buffer.from(png)) as unknown as ContentImageSource]]),
};
};
+7 -2
View File
@@ -47,7 +47,6 @@ type DocumentToSeed = {
export const seedDocuments = async (documents: DocumentToSeed[]) => {
await Promise.all(
// eslint-disable-next-line @typescript-eslint/require-await
documents.map(async (document, i) =>
match(document.type)
.with(DocumentStatus.DRAFT, async () =>
@@ -67,7 +66,13 @@ export const seedDocuments = async (documents: DocumentToSeed[]) => {
key: i,
createDocumentOptions: document.documentOptions,
}),
),
)
// Ending the match returns the seeding promise for `Promise.all` to
// wait on. Without it the match object itself is returned, and the
// documents are still being written when this resolves.
.otherwise(() => {
throw new Error(`Seeding ${document.type} documents is not supported`);
}),
),
);
};
+3 -5
View File
@@ -4,6 +4,7 @@ import path from 'node:path';
import {
CONTENT_ALIGNMENT_LOGO_PNG,
CONTENT_ALIGNMENT_TEST_CONTENTS,
getContentAlignmentCell,
} from '@documenso/app-tests/constants/content-alignment-pdf';
import { ALIGNMENT_TEST_FIELDS } from '@documenso/app-tests/constants/field-alignment-pdf';
import { FIELD_META_TEST_FIELDS } from '@documenso/app-tests/constants/field-meta-pdf';
@@ -551,16 +552,13 @@ export const seedContentAlignmentTestDocument = async ({
const recipientId = recipients[0].id;
const envelopeItemId = envelopeItems[0].id;
// The one field, in the spare cell of the text page's rotation row.
// The one field, in the free row below the text page's grid.
await prisma.field.create({
data: {
type: 'SIGNATURE',
fieldMeta: { type: 'signature', overflow: 'auto' },
page: 1,
positionX: 66,
positionY: 60,
width: 28,
height: 7,
...getContentAlignmentCell(6, 0),
recipientId,
envelopeItemId,
envelopeId: id,
@@ -53,8 +53,10 @@ export const ZCreateEnvelopePayloadSchema = z.object({
fields: ZEnvelopeFieldAndMetaSchema.and(
z.object({
identifier: z
.union([z.string(), z.number()])
.describe('Either the filename or the index of the file that was uploaded to attach the field to.')
.union([z.string(), z.number().int().min(0)])
.describe(
'Either the filename or the zero-based index of the file that was uploaded to attach the field to.',
)
.optional(),
page: ZFieldPageNumberSchema,
positionX: ZClampedFieldPositionXSchema,
@@ -71,8 +73,8 @@ export const ZCreateEnvelopePayloadSchema = z.object({
contents: z
.object({
identifier: z
.union([z.string(), z.number()])
.describe('Either the filename or the index of the file that was uploaded to attach the content to.')
.union([z.string(), z.number().int().min(0)])
.describe('Either the filename or the zero-based index of the file that was uploaded to attach the content to.')
.optional(),
contentMeta: ZEnvelopeContentMetaSchema.describe('All the properties of the content being placed.'),
imageIndex: z
@@ -22,7 +22,8 @@ const GRID = {
columnWidth: 28,
columnGap: 1,
rowHeight: 7,
rowGap: 1,
// Holds each box's label, which sits just above the box.
rowGap: 2,
};
const cellLeft = (column) => GRID.startX + column * (GRID.columnWidth + GRID.columnGap);
@@ -34,8 +35,11 @@ function makeCell(row, column, label, { rows = 1, columns = 1 } = {}) {
const width = columns * GRID.columnWidth + (columns - 1) * GRID.columnGap;
const height = rows * GRID.rowHeight + (rows - 1) * GRID.rowGap;
// The label is anchored to the top of its box and lifted by its own height
// (see `.label`), so it sits in the gap above the box rather than reaching
// into the row above.
return `
<div class="label" style="left:${left}%;top:${top - 1.4}%;">${label}</div>
<div class="label" style="left:${left}%;top:${top}%;">${label}</div>
<div class="box" style="left:${left}%;top:${top}%;width:${width}%;height:${height}%;"></div>`;
}
@@ -72,10 +76,6 @@ function makeTextPage() {
content += makeCell(4, 1, 'LETTER_SPACING_3');
content += makeCell(4, 2, 'WRAPPED', { rows: 2 });
content += makeRowLabel(6, 'ROTATION');
content += makeCell(6, 0, 'ROTATED_15', { rows: 2 });
content += makeCell(6, 1, 'ROTATED_90', { rows: 2, columns: 1 });
return content;
}
@@ -161,10 +161,6 @@ function makeHighlightPage() {
content += makeCell(2, 0, 'HIGHLIGHT OVER THIS PRINTED TEXT', { columns: 3 });
content += `<div class="body-text" style="left:${cellLeft(0) + 1}%;top:${cellTop(2) + 2}%;">The quick brown fox jumps over the lazy dog. A highlight is drawn over this line.</div>`;
content += makeRowLabel(3, 'ROTATION');
content += makeCell(3, 0, 'ROTATED_15', { rows: 2 });
content += makeCell(3, 1, 'ROTATED_45', { rows: 2 });
return content;
}
@@ -210,7 +206,7 @@ const html = `<!DOCTYPE html>
.row-label { position: absolute; font-size: 7px; font-weight: bold; color: #666; width: 6%; }
.body-text { position: absolute; font-size: 11px; color: #333; }
.box { position: absolute; border: 1px solid #999; box-sizing: border-box; }
.label { position: absolute; font-size: 7px; color: #c00; font-weight: bold; z-index: 9999; }
.label { position: absolute; font-size: 7px; line-height: 1; white-space: nowrap; color: #c00; font-weight: bold; z-index: 9999; transform: translateY(calc(-100% - 4px)); }
</style>
</head>
<body>