This commit is contained in:
David Nguyen
2026-08-26 12:10:29 +10:00
parent 9dc83bdb06
commit 6db71b13d4
70 changed files with 5964 additions and 892 deletions
@@ -6,7 +6,7 @@ import { DEFAULT_EMBEDDED_EDITOR_CONFIG } from '@documenso/lib/types/envelope-ed
import { seedBlankDocument } from '@documenso/prisma/seed/documents';
import { seedBlankTemplate } from '@documenso/prisma/seed/templates';
import { seedUser } from '@documenso/prisma/seed/users';
import type { Page } from '@playwright/test';
import type { Locator, Page } from '@playwright/test';
import { expect } from '@playwright/test';
import { apiSignin } from './authentication';
@@ -264,8 +264,6 @@ export const getRecipientRows = (root: Page) =>
export const getRecipientRemoveButtons = (root: Page) => root.locator('[data-testid="remove-signer-button"]');
export const getSigningOrderInputs = (root: Page) => root.locator('[data-testid="signing-order-input"]');
export const clickEnvelopeEditorStep = async (root: Page, stepId: 'upload' | 'addFields' | 'preview') => {
await root.waitForTimeout(200);
await root.locator(`[data-testid="envelope-editor-step-${stepId}"]`).first().click();
@@ -335,10 +333,334 @@ export const toggleAllowDictateSigners = async (root: Page, enabled: boolean) =>
}
};
export const setSigningOrderValue = async (root: Page, index: number, value: number) => {
const input = getSigningOrderInputs(root).nth(index);
await input.fill(value.toString());
await input.blur();
/**
* Performs a mouse-based drag from a drag handle onto a target element.
*
* `@hello-pangea/dnd` only starts a drag once the pointer travels a small
* distance while pressed, and it hit-tests drop targets using the CENTRE of
* the dragged element — not the cursor. Since drag handles sit at the edge of
* wide rows/cards, the cursor destination is compensated so the dragged
* element's centre lands on the target's centre.
*/
export const dragHandleToTarget = async (
root: Page,
handle: Locator,
target: Locator,
options: { activeClass: string },
) => {
const { activeClass } = options;
await handle.scrollIntoViewIfNeeded();
const handleBox = await handle.boundingBox();
if (!handleBox) {
throw new Error('Unable to resolve drag handle position');
}
const startX = handleBox.x + handleBox.width / 2;
const startY = handleBox.y + handleBox.height / 2;
await root.mouse.move(startX, startY);
await root.mouse.down();
// Exceed the drag activation threshold, then wait for drag-dependent layout
// (e.g. expanding gap drop-zones) to settle before resolving positions.
const cursorX = startX + 8;
const cursorY = startY;
await root.mouse.move(cursorX, cursorY, { steps: 2 });
await root.waitForTimeout(300);
// The dragged element is the handle's draggable ancestor; while dragging it
// is fixed-positioned and follows the cursor at a constant offset. Drop
// targeting uses the dragged element's CENTRE, not the cursor, so the
// cursor destination is compensated by that offset.
const draggedElement = handle.locator('xpath=ancestor-or-self::*[@data-rfd-draggable-id][1]');
const draggedBox = await draggedElement.boundingBox();
const targetBox = await target.boundingBox();
if (!draggedBox || !targetBox) {
await root.mouse.up();
throw new Error('Unable to resolve drag positions');
}
const itemOffsetX = draggedBox.x + draggedBox.width / 2 - cursorX;
const itemOffsetY = draggedBox.y + draggedBox.height / 2 - cursorY;
const hasBecomeActive = async () => {
const className = await target.getAttribute('class');
return Boolean(className?.includes(activeClass));
};
// The highlight class is rendered from the library's own drag state, so it
// cannot disagree with where a drop will land — both phases below only drop
// once the target reports the drag as over it AND that state survives a
// short confirmation dwell (it can flicker while crossing a card's
// reorder/combine boundary).
//
// The cursor is always clamped inside the viewport: moving outside the
// window cancels the drag (pointercancel), and holding near the bottom edge
// lets the library auto-scroll the target up to the cursor instead.
const viewportHeight = root.viewportSize()?.height ?? 720;
const maxCursorY = viewportHeight - 40;
const confirmAndDrop = async () => {
if (!(await hasBecomeActive())) {
return false;
}
await root.waitForTimeout(150);
if (!(await hasBecomeActive())) {
return false;
}
await root.mouse.up();
return true;
};
let hasDropped = false;
// Crawl-and-drop: approach from above and inch downward through the
// corridor. Captured drop-target geometry can drift a few pixels from the
// live layout for small targets, so a slow traversal is the reliable way to
// hit them.
const crawlX = targetBox.x + targetBox.width / 2 - itemOffsetX;
const crawlStartY = Math.min(targetBox.y + targetBox.height / 2 - itemOffsetY - 140, maxCursorY);
await root.mouse.move(crawlX, crawlStartY, { steps: 15 });
await root.waitForTimeout(150);
for (let step = 1; step <= 80; step += 1) {
if (await confirmAndDrop()) {
hasDropped = true;
break;
}
await root.mouse.move(crawlX, Math.min(crawlStartY + step * 6, maxCursorY), { steps: 2 });
await root.waitForTimeout(70);
}
if (!hasDropped) {
await root.mouse.up();
}
await root.waitForTimeout(400);
};
export const getRecipientStepCards = (root: Page) => root.locator('[data-testid="recipient-step-card"]');
export const getRecipientStepGaps = (root: Page) => root.locator('[data-testid="recipient-step-gap"]');
export const getStepDragHandles = (root: Page) => root.locator('[data-testid="step-drag-handle"]');
export const getRecipientRowDragHandles = (root: Page) => root.locator('[data-testid="recipient-row-drag-handle"]');
/**
* Drags a whole group card onto another card, merging the two groups.
*
* Uses @hello-pangea/dnd's keyboard drag mode: mouse-emulated combines are
* unreliable because approaching a card traverses its reorder edge, which
* displaces the target away from the cursor. Keyboard drags step through
* positions (including combine states) deterministically.
*/
export const dragGroupCardOntoCard = async (root: Page, sourceCardIndex: number, targetCardIndex: number) => {
const handle = getStepDragHandles(root).nth(sourceCardIndex);
const target = getRecipientStepCards(root).nth(targetCardIndex);
await handle.scrollIntoViewIfNeeded();
await handle.focus();
// Lift.
await root.keyboard.press('Space');
await root.waitForTimeout(250);
const direction = targetCardIndex < sourceCardIndex ? 'ArrowUp' : 'ArrowDown';
for (let press = 0; press < 4; press += 1) {
await root.keyboard.press(direction);
await root.waitForTimeout(250);
const targetClassName = await target.getAttribute('class');
if (targetClassName?.includes('ring-primary')) {
// Drop while the target reports the combine state.
await root.keyboard.press('Space');
await root.waitForTimeout(400);
return;
}
}
await root.keyboard.press('Escape');
throw new Error('Combine drag did not reach the target card');
};
/**
* Moves a group card one position up via keyboard drag. With combining
* enabled, the first ArrowUp enters the combine state with the card above and
* the second moves above it.
*/
export const moveGroupCardUp = async (root: Page, cardIndex: number) => {
const handle = getStepDragHandles(root).nth(cardIndex);
await handle.scrollIntoViewIfNeeded();
await handle.focus();
await root.keyboard.press('Space');
await root.waitForTimeout(250);
await root.keyboard.press('ArrowUp');
await root.waitForTimeout(250);
await root.keyboard.press('ArrowUp');
await root.waitForTimeout(250);
await root.keyboard.press('Space');
await root.waitForTimeout(400);
};
/**
* Drags a recipient row into a gap between group cards, extracting it into
* its own standalone group at that position.
*/
export const dragRecipientRowToGap = async (root: Page, rowIndex: number, gapIndex: number) => {
await dragHandleToTarget(
root,
getRecipientRowDragHandles(root).nth(rowIndex),
getRecipientStepGaps(root).nth(gapIndex),
// The marker class applied to a gap drop-zone while dragged over.
{ activeClass: 'gap-active' },
);
};
export type SweepRecipientRowOverCardResult = {
/**
* Whether any gap drop-zone activated during the sweep — proof the drag
* gesture itself was live, so "the card never activated" cannot be a
* false negative from a drag that silently failed to start.
*/
sawGapActive: boolean;
/**
* Whether the target card reported the row as a join target (`ring-primary`).
*/
sawCardActive: boolean;
/**
* Whether the row was dropped onto the card (only when it became active).
*/
dropped: boolean;
};
/**
* Drags a recipient row across a group card's body, dropping it to join the
* group as soon as the card activates. If the card never activates (e.g. the
* join drop-zone is disabled), the drag is cancelled with Escape so no
* accidental gap-drop mutates the order.
*
* Unlike `dragHandleToTarget`'s fixed-interval crawl, each sweep position
* polls for activation with a generous budget, which keeps the gesture
* reliable when rendering lags under parallel test load.
*/
export const sweepRecipientRowOverCard = async (
root: Page,
rowIndex: number,
cardIndex: number,
): Promise<SweepRecipientRowOverCardResult> => {
const handle = getRecipientRowDragHandles(root).nth(rowIndex);
const card = getRecipientStepCards(root).nth(cardIndex);
const result: SweepRecipientRowOverCardResult = {
sawGapActive: false,
sawCardActive: false,
dropped: false,
};
await handle.scrollIntoViewIfNeeded();
const handleBox = await handle.boundingBox();
if (!handleBox) {
throw new Error('Unable to resolve drag handle position');
}
const startX = handleBox.x + handleBox.width / 2;
const startY = handleBox.y + handleBox.height / 2;
await root.mouse.move(startX, startY);
await root.mouse.down();
// Exceed the drag activation threshold, then wait for drag-dependent
// layout (expanding drop-zones) to settle before resolving positions.
const cursorX = startX + 8;
const cursorY = startY;
await root.mouse.move(cursorX, cursorY, { steps: 2 });
await root.waitForTimeout(300);
// Drop targeting uses the dragged element's CENTRE, not the cursor, so
// cursor destinations are compensated by the constant cursor-to-centre
// offset captured at lift time.
const draggedElement = handle.locator('xpath=ancestor-or-self::*[@data-rfd-draggable-id][1]');
const draggedBox = await draggedElement.boundingBox();
const cardBox = await card.boundingBox();
if (!draggedBox || !cardBox) {
await root.mouse.up();
throw new Error('Unable to resolve drag positions');
}
const itemOffsetX = draggedBox.x + draggedBox.width / 2 - cursorX;
const itemOffsetY = draggedBox.y + draggedBox.height / 2 - cursorY;
const sweepX = cardBox.x + cardBox.width / 2 - itemOffsetX;
const sweepFromY = cardBox.y - itemOffsetY - 40;
const sweepToY = cardBox.y + cardBox.height - itemOffsetY + 80;
await root.mouse.move(sweepX, sweepFromY, { steps: 15 });
for (let y = sweepFromY; y <= sweepToY && !result.dropped; y += 8) {
await root.mouse.move(sweepX, y, { steps: 2 });
// Poll for activation: drag state is rendered on animation frames, so
// under load the classes can trail the cursor by hundreds of ms.
for (let tick = 0; tick < 6; tick += 1) {
const cardClassName = (await card.getAttribute('class')) ?? '';
if (cardClassName.includes('ring-primary')) {
result.sawCardActive = true;
await root.mouse.up();
result.dropped = true;
break;
}
if (!result.sawGapActive) {
const activeGapCount = await root.locator('[data-testid="recipient-step-gap"].gap-active').count();
result.sawGapActive = activeGapCount > 0;
}
await root.waitForTimeout(50);
}
}
if (!result.dropped) {
// Cancel rather than release: releasing over an active gap would extract
// the row into a new step, silently mutating the signing order.
await root.keyboard.press('Escape');
await root.waitForTimeout(100);
await root.mouse.up();
}
await root.waitForTimeout(400);
return result;
};
export const persistEmbeddedEnvelope = async (surface: TEnvelopeEditorSurface) => {