TidGi-Desktop/features/stepDefinitions/workspaceGroup.ts

607 lines
24 KiB
TypeScript

import { DataTable, Given, Then, When } from '@cucumber/cucumber';
import { backOff } from 'exponential-backoff';
import type { IWorkspaceGroup } from '../../src/services/workspaces/interface';
// Pull in renderer window type declarations so Playwright page.evaluate callbacks
// can access window.service with proper typing.
import type {} from '../../src/preload/index';
import type { ApplicationWorld } from './application';
const BACKOFF_OPTIONS = {
numOfAttempts: 8,
startingDelay: 100,
maxDelay: 1000,
timeMultiple: 2,
};
interface ITestWorkspace {
id: string;
name: string;
groupId?: string | null;
order?: number;
pageType?: string | null;
}
interface IWorkspaceOrGroupOrderEntry {
name: string;
order: number;
type: 'workspace' | 'group';
}
async function getAllWikiWorkspaces(world: ApplicationWorld): Promise<ITestWorkspace[]> {
if (!world.currentWindow) {
throw new Error('Current window not set');
}
return await world.currentWindow.evaluate(async () => {
const all = await window.service.workspace.getWorkspacesAsList();
return all.filter(workspace => !workspace.pageType) as ITestWorkspace[];
});
}
async function getWorkspaceByName(world: ApplicationWorld, workspaceName: string): Promise<ITestWorkspace> {
const workspaces = await getAllWikiWorkspaces(world);
const workspace = workspaces.find((candidate) => candidate.name === workspaceName);
if (!workspace) {
throw new Error(
`Workspace "${workspaceName}" not found. Existing wiki workspaces: ${workspaces.map(candidate => candidate.name).join(', ')}`,
);
}
return workspace;
}
async function getGroups(world: ApplicationWorld): Promise<IWorkspaceGroup[]> {
if (!world.currentWindow) {
throw new Error('Current window not set');
}
return await world.currentWindow.evaluate(async () => window.service.workspace.getGroupsAsList());
}
async function getGroupWorkspaces(world: ApplicationWorld, groupId: string): Promise<ITestWorkspace[]> {
const workspaces = await getAllWikiWorkspaces(world);
return workspaces.filter(workspace => workspace.groupId === groupId);
}
async function getSidebarOrderEntries(world: ApplicationWorld): Promise<IWorkspaceOrGroupOrderEntry[]> {
const [workspaces, groups] = await Promise.all([getAllWikiWorkspaces(world), getGroups(world)]);
return [
...workspaces.filter(workspace => !workspace.groupId).map(workspace => ({
name: workspace.name,
order: workspace.order ?? 0,
type: 'workspace' as const,
})),
...groups.map(group => ({
name: group.name,
order: group.order ?? 0,
type: 'group' as const,
})),
].sort((left, right) => left.order - right.order);
}
async function createGroup(world: ApplicationWorld, groupName: string): Promise<IWorkspaceGroup> {
const groups = await getGroups(world);
const newGroup: IWorkspaceGroup = {
id: `test-group-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
name: groupName,
order: groups.length,
collapsed: false,
};
if (!world.currentWindow) {
throw new Error('Current window not set');
}
await world.currentWindow.evaluate(async (group: IWorkspaceGroup) => {
await window.service.workspace.setGroup(group.id, group);
}, newGroup);
return newGroup;
}
async function moveWorkspaceToGroup(world: ApplicationWorld, workspaceId: string, groupId: string | null, autoDisband = true): Promise<void> {
if (!world.currentWindow) {
throw new Error('Current window not set');
}
await world.currentWindow.evaluate(async ({ workspaceId: id, groupId: gid, autoDisband: disband }: { workspaceId: string; groupId: string | null; autoDisband: boolean }) => {
await window.service.workspace.moveWorkspaceToGroup(id, gid, disband);
}, { workspaceId, groupId, autoDisband });
}
async function waitForWorkspaceGroupId(world: ApplicationWorld, workspaceName: string, expectedGroupId: string | null): Promise<void> {
await backOff(async () => {
const workspace = await getWorkspaceByName(world, workspaceName);
const actualGroupId = workspace.groupId ?? null;
if (actualGroupId !== expectedGroupId) {
throw new Error(`Workspace "${workspaceName}" groupId is ${String(actualGroupId)}, expected ${String(expectedGroupId)}`);
}
}, BACKOFF_OPTIONS);
}
async function waitForGroupVisibility(world: ApplicationWorld, groupId: string): Promise<void> {
await backOff(async () => {
if (!world.currentWindow) {
throw new Error('Current window not set');
}
const count = await world.currentWindow.locator(`[data-testid="workspace-group-${groupId}"]`).count();
if (count === 0) {
throw new Error(`Group ${groupId} not visible yet`);
}
}, BACKOFF_OPTIONS);
}
async function waitForGroupedWorkspaceDomState(world: ApplicationWorld, groupId: string, shouldBeVisible: boolean): Promise<void> {
await backOff(async () => {
if (!world.currentWindow) {
throw new Error('Current window not set');
}
const groupedWorkspaces = await getGroupWorkspaces(world, groupId);
for (const workspace of groupedWorkspaces) {
const itemCount = await world.currentWindow.locator(`[data-testid="workspace-item-${workspace.id}"]`).count();
const topDropZoneCount = await world.currentWindow.locator(`[data-testid="workspace-drop-zone-${workspace.id}-top"]`).count();
if (shouldBeVisible && (itemCount === 0 || topDropZoneCount === 0)) {
throw new Error(`Grouped workspace "${workspace.name}" is not fully visible yet`);
}
if (!shouldBeVisible && (itemCount !== 0 || topDropZoneCount !== 0)) {
throw new Error(`Grouped workspace "${workspace.name}" is still visible`);
}
}
}, BACKOFF_OPTIONS);
}
async function dragLocatorToCoordinates(
world: ApplicationWorld,
sourceSelector: string,
resolveTargetCoordinates: () => Promise<{ targetX: number; targetY: number }>,
): Promise<void> {
if (!world.currentWindow) {
throw new Error('Current window not set');
}
const sourceLocator = world.currentWindow.locator(sourceSelector);
await sourceLocator.waitFor({ state: 'visible' });
await sourceLocator.scrollIntoViewIfNeeded();
const sourceBox = await sourceLocator.boundingBox();
if (!sourceBox) {
throw new Error(`Could not read bounding box for ${sourceSelector}`);
}
const startX = sourceBox.x + sourceBox.width / 2;
const startY = sourceBox.y + sourceBox.height / 2;
const initialTargetCoordinates = await resolveTargetCoordinates();
await world.currentWindow.mouse.move(startX, startY);
await world.currentWindow.mouse.down();
// Small initial movement to satisfy dnd-kit PointerSensor activationConstraint (distance: 8)
await world.currentWindow.mouse.move(startX + 12, startY + 12, { steps: 6 });
await world.currentWindow.waitForTimeout(100);
// Move to target with a short smooth path
await world.currentWindow.mouse.move(initialTargetCoordinates.targetX, initialTargetCoordinates.targetY, { steps: 3 });
await world.currentWindow.waitForTimeout(100);
// Re-track the target in case the DOM shifted during the drag (e.g. due to
// visual reordering). Keep adjusting the mouse until the target stabilises
// or we hit a reasonable attempt limit.
let previousTargetCoordinates = await resolveTargetCoordinates();
for (let attempt = 0; attempt < 5; attempt++) {
await world.currentWindow.mouse.move(previousTargetCoordinates.targetX, previousTargetCoordinates.targetY, { steps: 1 });
await world.currentWindow.waitForTimeout(80);
const currentTargetCoordinates = await resolveTargetCoordinates();
const delta = Math.abs(currentTargetCoordinates.targetY - previousTargetCoordinates.targetY);
if (delta < 3) {
break;
}
previousTargetCoordinates = currentTargetCoordinates;
}
await world.currentWindow.mouse.up();
}
async function dragLocatorAndHoldAtCoordinates(
world: ApplicationWorld,
sourceSelector: string,
resolveTargetCoordinates: () => Promise<{ targetX: number; targetY: number }>,
): Promise<void> {
if (!world.currentWindow) {
throw new Error('Current window not set');
}
const sourceLocator = world.currentWindow.locator(sourceSelector);
await sourceLocator.waitFor({ state: 'visible' });
await sourceLocator.scrollIntoViewIfNeeded();
const sourceBox = await sourceLocator.boundingBox();
if (!sourceBox) {
throw new Error(`Could not read bounding box for ${sourceSelector}`);
}
const startX = sourceBox.x + sourceBox.width / 2;
const startY = sourceBox.y + sourceBox.height / 2;
await world.currentWindow.mouse.move(startX, startY);
await world.currentWindow.mouse.down();
await world.currentWindow.mouse.move(startX + 12, startY + 12, { steps: 6 });
const initialTargetCoordinates = await resolveTargetCoordinates();
await world.currentWindow.mouse.move(initialTargetCoordinates.targetX, initialTargetCoordinates.targetY, { steps: 3 });
await world.currentWindow.waitForTimeout(40);
}
async function getLocatorCenter(
world: ApplicationWorld,
targetSelector: string,
): Promise<{ targetX: number; targetY: number }> {
if (!world.currentWindow) {
throw new Error('Current window not set');
}
for (let attempt = 0; attempt < 4; attempt++) {
const rect = await world.currentWindow.evaluate((selector: string) => {
const element = document.querySelector(selector);
if (!element) return null;
const r = element.getBoundingClientRect();
return { x: r.left, y: r.top, width: r.width, height: r.height };
}, targetSelector);
if (rect) {
return {
targetX: rect.x + rect.width / 2,
targetY: rect.y + rect.height / 2,
};
}
if (attempt === 3) {
const testIds = await world.currentWindow.evaluate(() => {
const elements = document.querySelectorAll('[data-testid]');
return Array.from(elements).map(element => element.getAttribute('data-testid')).filter(Boolean);
});
throw new Error(
`Could not read bounding box for ${targetSelector}. Current DOM testids: ${testIds.join(', ')}`,
);
}
await new Promise(resolve => setTimeout(resolve, 100));
}
throw new Error(`Could not read bounding box for ${targetSelector}`);
}
Given('workspace group {string} contains workspaces:', async function(this: ApplicationWorld, groupName: string, dataTable: DataTable) {
const rows = dataTable.raw().map(([workspaceName]: string[]) => workspaceName).filter((workspaceName): workspaceName is string => Boolean(workspaceName));
const group = await createGroup(this, groupName);
for (const workspaceName of rows) {
const workspace = await getWorkspaceByName(this, workspaceName);
await moveWorkspaceToGroup(this, workspace.id, group.id);
await waitForWorkspaceGroupId(this, workspaceName, group.id);
}
await waitForGroupVisibility(this, group.id);
// Wait for every workspace in the group to actually appear in the DOM
// so that subsequent drag steps can locate their drop zones.
for (const workspaceName of rows) {
const workspace = await getWorkspaceByName(this, workspaceName);
await backOff(async () => {
if (!this.currentWindow) {
throw new Error('Current window not set');
}
const itemCount = await this.currentWindow.locator(`[data-testid="workspace-item-${workspace.id}"]`).count();
if (itemCount === 0) {
throw new Error(`Workspace item "${workspaceName}" not yet rendered in DOM`);
}
const dropZoneCount = await this.currentWindow.locator(`[data-testid="workspace-drop-zone-${workspace.id}-top"]`).count();
if (dropZoneCount === 0) {
throw new Error(`Workspace drop zone "${workspaceName}" not yet rendered in DOM`);
}
}, BACKOFF_OPTIONS);
}
});
When('I drag workspace {string} onto workspace {string}', async function(this: ApplicationWorld, sourceWorkspaceName: string, targetWorkspaceName: string) {
if (!this.currentWindow) {
throw new Error('Current window not set');
}
const sourceWorkspace = await getWorkspaceByName(this, sourceWorkspaceName);
const targetWorkspace = await getWorkspaceByName(this, targetWorkspaceName);
const targetSelector = `[data-testid="workspace-drop-zone-${targetWorkspace.id}-center"]`;
const targetLocator = this.currentWindow.locator(targetSelector);
await targetLocator.waitFor({ state: 'visible' });
await dragLocatorToCoordinates(
this,
`[data-testid="workspace-item-${sourceWorkspace.id}"]`,
async () => getLocatorCenter(this, targetSelector),
);
});
When('I hover workspace {string} over workspace {string}', async function(this: ApplicationWorld, sourceWorkspaceName: string, targetWorkspaceName: string) {
if (!this.currentWindow) {
throw new Error('Current window not set');
}
const sourceWorkspace = await getWorkspaceByName(this, sourceWorkspaceName);
const targetWorkspace = await getWorkspaceByName(this, targetWorkspaceName);
const targetSelector = `[data-testid="workspace-drop-zone-${targetWorkspace.id}-center"]`;
const targetLocator = this.currentWindow.locator(targetSelector);
await targetLocator.waitFor({ state: 'visible' });
await dragLocatorAndHoldAtCoordinates(this, `[data-testid="workspace-item-${sourceWorkspace.id}"]`, async () => {
return getLocatorCenter(this, targetSelector);
});
});
When('I release the mouse', async function(this: ApplicationWorld) {
if (!this.currentWindow) {
throw new Error('Current window not set');
}
await this.currentWindow.mouse.up();
});
When('I drag workspace {string} to the top zone of workspace {string}', async function(this: ApplicationWorld, sourceWorkspaceName: string, targetWorkspaceName: string) {
if (!this.currentWindow) {
throw new Error('Current window not set');
}
const sourceWorkspace = await getWorkspaceByName(this, sourceWorkspaceName);
const targetWorkspace = await getWorkspaceByName(this, targetWorkspaceName);
const targetSelector = `[data-testid="workspace-drop-zone-${targetWorkspace.id}-top"]`;
const targetLocator = this.currentWindow.locator(targetSelector);
await targetLocator.waitFor({ state: 'visible' });
await dragLocatorToCoordinates(this, `[data-testid="workspace-item-${sourceWorkspace.id}"]`, async () => {
return getLocatorCenter(this, targetSelector);
});
});
When('I drag workspace {string} to the bottom zone of workspace {string}', async function(this: ApplicationWorld, sourceWorkspaceName: string, targetWorkspaceName: string) {
if (!this.currentWindow) {
throw new Error('Current window not set');
}
const sourceWorkspace = await getWorkspaceByName(this, sourceWorkspaceName);
const targetWorkspace = await getWorkspaceByName(this, targetWorkspaceName);
const targetSelector = `[data-testid="workspace-drop-zone-${targetWorkspace.id}-bottom"]`;
const targetLocator = this.currentWindow.locator(targetSelector);
await targetLocator.waitFor({ state: 'visible' });
await dragLocatorToCoordinates(this, `[data-testid="workspace-item-${sourceWorkspace.id}"]`, async () => {
return getLocatorCenter(this, targetSelector);
});
});
When('I drag workspace {string} onto the header of its current group', async function(this: ApplicationWorld, workspaceName: string) {
if (!this.currentWindow) {
throw new Error('Current window not set');
}
const workspace = await getWorkspaceByName(this, workspaceName);
if (!workspace.groupId) {
throw new Error(`Workspace "${workspaceName}" is not currently grouped`);
}
const sourceSelector = `[data-testid="workspace-item-${workspace.id}"]`;
const groupHeaderSelector = `[data-testid="workspace-group-${workspace.groupId}"]`;
if (!this.currentWindow) {
throw new Error('Current window not set');
}
const targetLocator = this.currentWindow.locator(groupHeaderSelector);
await targetLocator.waitFor({ state: 'visible' });
await dragLocatorToCoordinates(
this,
sourceSelector,
async () => getLocatorCenter(this, groupHeaderSelector),
);
});
Then('workspaces {string} and {string} should share a group', async function(this: ApplicationWorld, firstWorkspaceName: string, secondWorkspaceName: string) {
await backOff(async () => {
const [firstWorkspace, secondWorkspace] = await Promise.all([
getWorkspaceByName(this, firstWorkspaceName),
getWorkspaceByName(this, secondWorkspaceName),
]);
if (!firstWorkspace.groupId || !secondWorkspace.groupId || firstWorkspace.groupId !== secondWorkspace.groupId) {
throw new Error(`Workspaces "${firstWorkspaceName}" and "${secondWorkspaceName}" do not share a group yet`);
}
}, BACKOFF_OPTIONS);
});
Then('workspace {string} should be ungrouped', async function(this: ApplicationWorld, workspaceName: string) {
await waitForWorkspaceGroupId(this, workspaceName, null);
});
Then('workspace {string} should be in a group', async function(this: ApplicationWorld, workspaceName: string) {
await backOff(async () => {
const workspace = await getWorkspaceByName(this, workspaceName);
if (!workspace.groupId) {
throw new Error(`Workspace "${workspaceName}" is not grouped`);
}
}, BACKOFF_OPTIONS);
});
Then('there should be {int} workspace groups', async function(this: ApplicationWorld, expectedCount: number) {
await backOff(async () => {
const groups = await getGroups(this);
if (groups.length !== expectedCount) {
throw new Error(`Expected ${expectedCount} workspace groups, found ${groups.length}`);
}
}, BACKOFF_OPTIONS);
});
Then('workspace {string} should appear before workspace {string}', async function(this: ApplicationWorld, firstWorkspaceName: string, secondWorkspaceName: string) {
await backOff(async () => {
const [firstWorkspace, secondWorkspace] = await Promise.all([
getWorkspaceByName(this, firstWorkspaceName),
getWorkspaceByName(this, secondWorkspaceName),
]);
const firstOrder = firstWorkspace.order ?? 0;
const secondOrder = secondWorkspace.order ?? 0;
if (firstOrder >= secondOrder) {
throw new Error(`Workspace "${firstWorkspaceName}" (order ${firstOrder}) should appear before "${secondWorkspaceName}" (order ${secondOrder})`);
}
}, BACKOFF_OPTIONS);
});
Then('workspace {string} should appear after workspace {string}', async function(this: ApplicationWorld, firstWorkspaceName: string, secondWorkspaceName: string) {
await backOff(async () => {
const [firstWorkspace, secondWorkspace] = await Promise.all([
getWorkspaceByName(this, firstWorkspaceName),
getWorkspaceByName(this, secondWorkspaceName),
]);
const firstOrder = firstWorkspace.order ?? 0;
const secondOrder = secondWorkspace.order ?? 0;
if (firstOrder <= secondOrder) {
throw new Error(`Workspace "${firstWorkspaceName}" (order ${firstOrder}) should appear after "${secondWorkspaceName}" (order ${secondOrder})`);
}
}, BACKOFF_OPTIONS);
});
Then('workspace {string} should show {string} drag intent', async function(this: ApplicationWorld, workspaceName: string, expectedIntent: string) {
if (!this.currentWindow) {
throw new Error('Current window not set');
}
await backOff(async () => {
const workspace = await getWorkspaceByName(this, workspaceName);
const selector = `[data-testid="workspace-item-${workspace.id}"] [data-drag-intent]`;
const actualIntent = await this.currentWindow?.locator(selector).getAttribute('data-drag-intent');
if (actualIntent !== expectedIntent) {
throw new Error(`Workspace "${workspaceName}" drag intent is ${String(actualIntent)}, expected ${expectedIntent}`);
}
}, BACKOFF_OPTIONS);
});
When('I collapse workspace group {string}', async function(this: ApplicationWorld, groupName: string) {
const groups = await getGroups(this);
const group = groups.find(g => g.name === groupName);
if (!group) {
throw new Error(`Group "${groupName}" not found`);
}
if (!this.currentWindow) {
throw new Error('Current window not set');
}
await this.currentWindow.evaluate(async (g: IWorkspaceGroup) => {
await window.service.workspace.setGroup(g.id, { ...g, collapsed: true });
}, group);
await waitForGroupedWorkspaceDomState(this, group.id, false);
});
When('I expand workspace group {string}', async function(this: ApplicationWorld, groupName: string) {
const groups = await getGroups(this);
const group = groups.find(g => g.name === groupName);
if (!group) {
throw new Error(`Group "${groupName}" not found`);
}
if (!this.currentWindow) {
throw new Error('Current window not set');
}
await this.currentWindow.evaluate(async (g: IWorkspaceGroup) => {
await window.service.workspace.setGroup(g.id, { ...g, collapsed: false });
}, group);
await waitForGroupVisibility(this, group.id);
await waitForGroupedWorkspaceDomState(this, group.id, true);
});
When('I drag group header {string} onto group header {string}', async function(this: ApplicationWorld, sourceGroupName: string, targetGroupName: string) {
if (!this.currentWindow) {
throw new Error('Current window not set');
}
const groups = await getGroups(this);
const sourceGroup = groups.find(g => g.name === sourceGroupName);
const targetGroup = groups.find(g => g.name === targetGroupName);
if (!sourceGroup) {
throw new Error(`Source group "${sourceGroupName}" not found`);
}
if (!targetGroup) {
throw new Error(`Target group "${targetGroupName}" not found`);
}
const sourceSelector = `[data-testid="workspace-group-${sourceGroup.id}"]`;
const targetSelector = `[data-testid="workspace-group-${targetGroup.id}"]`;
const targetLocator = this.currentWindow.locator(targetSelector);
await targetLocator.waitFor({ state: 'visible' });
await dragLocatorToCoordinates(this, sourceSelector, async () => {
return await getLocatorCenter(this, targetSelector);
});
});
When('I drag group header {string} onto workspace {string}', async function(this: ApplicationWorld, sourceGroupName: string, targetWorkspaceName: string) {
if (!this.currentWindow) {
throw new Error('Current window not set');
}
const groups = await getGroups(this);
const sourceGroup = groups.find(group => group.name === sourceGroupName);
if (!sourceGroup) {
throw new Error(`Source group "${sourceGroupName}" not found`);
}
const targetWorkspace = await getWorkspaceByName(this, targetWorkspaceName);
const sourceSelector = `[data-testid="workspace-group-${sourceGroup.id}"]`;
const targetSelector = `[data-testid="workspace-item-${targetWorkspace.id}"]`;
const targetLocator = this.currentWindow.locator(targetSelector);
await targetLocator.waitFor({ state: 'visible' });
await dragLocatorToCoordinates(this, sourceSelector, async () => {
return await getLocatorCenter(this, targetSelector);
});
});
Then('group {string} should appear before group {string}', async function(this: ApplicationWorld, firstGroupName: string, secondGroupName: string) {
await backOff(async () => {
const groups = await getGroups(this);
const firstGroup = groups.find(g => g.name === firstGroupName);
const secondGroup = groups.find(g => g.name === secondGroupName);
if (!firstGroup) {
throw new Error(`Group "${firstGroupName}" not found`);
}
if (!secondGroup) {
throw new Error(`Group "${secondGroupName}" not found`);
}
const firstOrder = firstGroup.order ?? 0;
const secondOrder = secondGroup.order ?? 0;
if (firstOrder >= secondOrder) {
throw new Error(`Group "${firstGroupName}" (order ${firstOrder}) should appear before "${secondGroupName}" (order ${secondOrder})`);
}
}, BACKOFF_OPTIONS);
});
Then('group {string} should appear before workspace {string}', async function(this: ApplicationWorld, groupName: string, workspaceName: string) {
await backOff(async () => {
const entries = await getSidebarOrderEntries(this);
const groupEntry = entries.find(entry => entry.type === 'group' && entry.name === groupName);
const workspaceEntry = entries.find(entry => entry.type === 'workspace' && entry.name === workspaceName);
if (!groupEntry) {
throw new Error(`Group "${groupName}" not found in sidebar entries: ${entries.map(entry => `${entry.type}:${entry.name}`).join(', ')}`);
}
if (!workspaceEntry) {
throw new Error(`Workspace "${workspaceName}" not found in sidebar entries: ${entries.map(entry => `${entry.type}:${entry.name}`).join(', ')}`);
}
if (groupEntry.order >= workspaceEntry.order) {
throw new Error(`Group "${groupName}" (order ${groupEntry.order}) should appear before workspace "${workspaceName}" (order ${workspaceEntry.order})`);
}
}, BACKOFF_OPTIONS);
});