super-productivity/e2e/tests/work-view/sections.spec.ts
Johannes Millan a4dee9d5f7
fix(sync): isolate provider encryption settings + enforce critical e2e coverage (#9044)
* docs(plugins): add microsoft 365 calendar provider plan

* docs(plugins): add microsoft 365 calendar provider plan

* docs(ios): plan internal testflight builds

* fix(sync): isolate provider encryption settings

* test: enforce critical end-to-end coverage
2026-07-15 14:24:49 +02:00

527 lines
20 KiB
TypeScript

import { expect, test } from '../../fixtures/test.fixture';
/**
* Basic e2e coverage for the Sections feature.
*
* Sections live inside a project (and tag/today, but project is the
* simpler isolated surface). Each test creates its own project so they
* stay independent and the testPrefix keeps names unique across workers.
*/
test.describe('Sections', () => {
/**
* Create a fresh project and navigate to it. Avoids
* `createAndGoToTestProject()` which hits a strict-mode `.nav-children`
* violation when both Projects and Tags trees are expanded.
*/
const setupTestProject = async (
workViewPage: import('../../pages/work-view.page').WorkViewPage,
projectPage: import('../../pages/project.page').ProjectPage,
projectName: string = 'Test Project',
): Promise<void> => {
await workViewPage.waitForTaskList();
await projectPage.createProject(projectName);
await projectPage.navigateToProjectByName(projectName);
};
/**
* Open the work-context menu via the page-title's `.project-settings-btn`
* (the more_vert icon next to the project title in the main header).
* This is the same menu the side-nav `additional-btn` opens, but the
* header trigger is always visible without hover and isn't sensitive to
* the tree's expand/collapse state.
*/
const openProjectContextMenu = async (
page: import('@playwright/test').Page,
): Promise<void> => {
const trigger = page.locator('.project-settings-btn');
await trigger.waitFor({ state: 'visible', timeout: 10000 });
await trigger.click();
await page
.locator('work-context-menu')
.first()
.waitFor({ state: 'visible', timeout: 5000 });
};
/** Click the "Add Section" item in the open work-context menu. */
const clickAddSection = async (
page: import('@playwright/test').Page,
): Promise<void> => {
await page.getByRole('menuitem', { name: 'Add Section' }).click();
};
/** Fill the dialog-prompt input and submit. */
const submitPromptDialog = async (
page: import('@playwright/test').Page,
title: string,
): Promise<void> => {
const dialog = page.locator('mat-dialog-container');
await dialog.waitFor({ state: 'visible', timeout: 5000 });
const input = dialog.locator('input[type="text"]').first();
await input.fill(title);
await dialog.getByRole('button', { name: 'Save' }).click();
await dialog.waitFor({ state: 'hidden', timeout: 5000 });
};
/** Locator for a rendered section block in the work view by title. */
const sectionByTitle = (
page: import('@playwright/test').Page,
title: string,
): ReturnType<import('@playwright/test').Page['locator']> =>
page.locator('.section-container').filter({ hasText: title });
/**
* Read a locator's bounding box defensively. `boundingBox()` takes a
* one-shot snapshot and returns `null` whenever the element has no
* layout box at that instant — which happens constantly here because
* the section task-list re-renders after every CDK drop (Angular's
* `@for track` reorders nodes) and freshly-dropped tasks run an
* `expandInOnly` enter animation that holds them at `height: 0`. Wait
* for the element to be visible, then poll until it reports a real box
* so the read can't race the re-render. This was the failure the former
* disabled cross-list scenarios hit; keeping the guard here prevents the
* active tests from throwing "no bounding box".
*
* The box captured *inside* the poll is the one returned — re-reading
* after the poll reopens the very race the poll closes (the list can
* re-render in the gap, making the second read return `null`).
*/
const stableBoundingBox = async (
locator: import('@playwright/test').Locator,
): Promise<{ x: number; y: number; width: number; height: number }> => {
await locator.waitFor({ state: 'visible', timeout: 10000 });
let box: { x: number; y: number; width: number; height: number } | null = null;
await expect
.poll(
async () => {
const b = await locator.boundingBox();
if (b && b.width > 0 && b.height > 0) {
box = b;
return true;
}
return false;
},
{ timeout: 10000 },
)
.toBe(true);
if (!box) throw new Error('drag source/target has no bounding box');
return box;
};
/**
* CDK drag-drop is event-driven via Angular CDK's own pointer-event
* handling. Playwright's `dragTo` uses HTML5 drag-and-drop events,
* which CDK ignores. Drive the gesture manually with multi-step mouse
* moves so the CDK threshold + drag start fires.
*/
const cdkDragTo = async (
page: import('@playwright/test').Page,
source: import('@playwright/test').Locator,
target: import('@playwright/test').Locator,
): Promise<void> => {
const sBox = await stableBoundingBox(source);
const tBox = await stableBoundingBox(target);
/* eslint-disable no-mixed-operators */
const sx = sBox.x + sBox.width / 2;
const sy = sBox.y + sBox.height / 2;
const tx = tBox.x + tBox.width / 2;
const ty = tBox.y + tBox.height / 2;
/* eslint-enable no-mixed-operators */
await page.mouse.move(sx, sy);
await page.mouse.down();
// Initial nudge past CDK's drag threshold (5px by default).
await page.mouse.move(sx + 10, sy + 10, { steps: 5 });
// Smooth move to target so each `mousemove` re-evaluates drop target.
await page.mouse.move(tx, ty, { steps: 20 });
await page.mouse.up();
};
/**
* Disable Angular animations for the current session before driving CDK
* drag gestures that reorder tasks *already inside* a section. Each drop
* re-creates the moved task as a fresh `@for` `:enter`, so it replays the
* `expandInOnly` animation (`height: 0 -> *`, expand.ani.ts) and has a
* zero-height layout box mid-animation. `boundingBox()` returns `null` for
* that, which is the root cause of the "drag source/target has no bounding
* box" flake in the former disabled cross-section scenarios.
*
* Flipping the app's own `isDisableAnimations` config toggles the
* `@HostBinding('@.disabled')` on the root component (app.component.ts),
* which disables every `@trigger` including `expandInOnly` — so dropped
* tasks render at full height instantly and their box never collapses.
*
* NOTE: the "drops a task into a section" test intentionally keeps
* animations on: it drags out of the no-section list into an *empty*
* section, and with animations off the source-removal reflow is instant,
* so the empty drop target can jump out from under the in-flight pointer
* and the drop misses. Reorder and cross-list scenarios disable them.
*/
const disableAnimations = async (
page: import('@playwright/test').Page,
): Promise<void> => {
await expect
.poll(
() =>
page.evaluate(() => {
const store = (
window as unknown as {
__e2eTestHelpers?: { store?: { dispatch: (a: unknown) => void } };
}
).__e2eTestHelpers?.store;
if (!store) return false;
store.dispatch({
// updateGlobalConfigSection
// (src/app/features/config/store/global-config.actions.ts)
type: '[Global Config] Update Global Config Section',
sectionKey: 'misc',
sectionCfg: { isDisableAnimations: true },
isSkipSnack: true,
});
return true;
}),
{ timeout: 10000 },
)
.toBe(true);
// `global-theme.service` mirrors the config onto a body class via an
// `effect()`. Waiting for it confirms the `@.disabled` binding has flipped
// before the first gesture reads layout.
await page
.locator('body.isDisableAnimations')
.waitFor({ state: 'attached', timeout: 5000 });
};
test('creates a section via the project context menu', async ({
page,
workViewPage,
projectPage,
}) => {
await setupTestProject(workViewPage, projectPage);
await openProjectContextMenu(page);
await clickAddSection(page);
await submitPromptDialog(page, 'My Section');
await expect(sectionByTitle(page, 'My Section')).toBeVisible();
});
test('creates a section via right-click on the work-view background', async ({
page,
workViewPage,
projectPage,
}) => {
await setupTestProject(workViewPage, projectPage);
// The app-level bg context menu (shared with "Change Settings") only
// opens when the click target itself matches the allowed selectors,
// not on descendants. dispatchEvent fires the contextmenu directly
// on the wrapper so target.matches('.task-list-wrapper') holds.
const wrapper = page.locator('.task-list-wrapper').first();
await wrapper.waitFor({ state: 'visible', timeout: 5000 });
await wrapper.dispatchEvent('contextmenu');
await page.getByRole('menuitem', { name: 'Add Section' }).click();
await submitPromptDialog(page, 'Right-Click Section');
await expect(sectionByTitle(page, 'Right-Click Section')).toBeVisible();
});
test('rejects whitespace-only section titles', async ({
page,
workViewPage,
projectPage,
}) => {
await setupTestProject(workViewPage, projectPage);
await openProjectContextMenu(page);
await clickAddSection(page);
const dialog = page.locator('mat-dialog-container');
await dialog.waitFor({ state: 'visible', timeout: 5000 });
const input = dialog.locator('input[type="text"]').first();
await input.fill(' ');
// The form's `required` validator considers whitespace truthy as a
// string, so Save dispatches — but the component-side trim guard
// (work-context-menu.component.ts) drops the dispatch. Verify no
// section appears.
await dialog.getByRole('button', { name: 'Save' }).click();
// Dialog may stay open (required validation) or close without effect.
// Either way the section list must remain empty — `toHaveCount`
// already polls so no separate wait is needed.
await expect(page.locator('.section-container')).toHaveCount(0, { timeout: 1500 });
});
test('edits an existing section title', async ({ page, workViewPage, projectPage }) => {
await setupTestProject(workViewPage, projectPage);
await openProjectContextMenu(page);
await clickAddSection(page);
await submitPromptDialog(page, 'Original');
const section = sectionByTitle(page, 'Original');
await expect(section).toBeVisible();
// Open the section's per-section menu and click Edit.
await section.locator('button[mat-icon-button]').click();
await page.getByRole('menuitem', { name: 'Edit' }).click();
const dialog = page.locator('mat-dialog-container');
await dialog.waitFor({ state: 'visible', timeout: 5000 });
const input = dialog.locator('input[type="text"]').first();
await input.fill('Renamed');
await dialog.getByRole('button', { name: 'Save' }).click();
await dialog.waitFor({ state: 'hidden', timeout: 5000 });
await expect(sectionByTitle(page, 'Renamed')).toBeVisible();
await expect(sectionByTitle(page, 'Original')).toHaveCount(0);
});
test('deletes a section after confirmation', async ({
page,
workViewPage,
projectPage,
}) => {
await setupTestProject(workViewPage, projectPage);
await openProjectContextMenu(page);
await clickAddSection(page);
await submitPromptDialog(page, 'Doomed');
const section = sectionByTitle(page, 'Doomed');
await expect(section).toBeVisible();
await section.locator('button[mat-icon-button]').click();
await page.getByRole('menuitem', { name: 'Delete' }).click();
const confirm = page.locator('dialog-confirm');
await confirm.waitFor({ state: 'visible', timeout: 5000 });
// The confirmation has an OK / Confirm button — match either.
await confirm
.getByRole('button', { name: /^(OK|Confirm)$/i })
.first()
.click();
await confirm.waitFor({ state: 'hidden', timeout: 5000 });
await expect(sectionByTitle(page, 'Doomed')).toHaveCount(0);
});
test('drops a task into a section via drag and drop', async ({
page,
workViewPage,
projectPage,
}) => {
await setupTestProject(workViewPage, projectPage);
// Add task BEFORE creating the section so it lives in the no-section
// bucket initially.
await workViewPage.addTask('Movable');
await page.waitForSelector('task', { state: 'visible' });
await openProjectContextMenu(page);
await clickAddSection(page);
await submitPromptDialog(page, 'Target');
const section = sectionByTitle(page, 'Target');
await expect(section).toBeVisible();
// The section's inner task-list is the drop target.
const sectionTaskList = section.locator('task-list').first();
const noSectionTaskList = page.locator('.no-section task-list').first();
// Drag handle is `done-toggle` (per task-dragdrop.spec.ts).
const task = page.locator('task').filter({ hasText: 'Movable' }).first();
const dragHandle = task.locator('done-toggle').first();
await cdkDragTo(page, dragHandle, sectionTaskList);
// Task should now render inside the section, not in the no-section list.
await expect(section.locator('task').filter({ hasText: 'Movable' })).toBeVisible({
timeout: 5000,
});
await expect(
noSectionTaskList.locator('task').filter({ hasText: 'Movable' }),
).toHaveCount(0);
});
test('reorders tasks within a section via drag and drop', async ({
page,
workViewPage,
projectPage,
}) => {
await setupTestProject(workViewPage, projectPage);
await disableAnimations(page);
await openProjectContextMenu(page);
await clickAddSection(page);
await submitPromptDialog(page, 'Box');
const section = sectionByTitle(page, 'Box');
await expect(section).toBeVisible();
// Drag three distinctly-named tasks into the section. The exact
// post-drag order isn't deterministic under headless CDK (depends on
// bounding-box centers), so we capture the actual order rather than
// assert one. The behavioral invariant we care about: AFTER an
// intra-section drag, the dragged task is no longer at its original
// index. Before the bug fix that didn't hold (display read
// workContext.taskIds order, ignoring section.taskIds reorders).
for (const name of ['Alpha', 'Beta', 'Gamma']) {
await workViewPage.addTask(name);
const t = page.locator('.no-section task').filter({ hasText: name }).first();
await cdkDragTo(
page,
t.locator('done-toggle').first(),
section.locator('task-list').first(),
);
await expect(section.locator('task').filter({ hasText: name })).toBeVisible({
timeout: 5000,
});
}
const sectionTaskTitles = async (): Promise<string[]> =>
(await section.locator('task:not(.ng-animating) .task-title').allTextContents())
.map((s) => s.trim())
.filter(Boolean);
const before = await sectionTaskTitles();
expect(before.length).toBe(3);
// Drag the first task onto the last task. After the drop, the first
// task should no longer be at index 0. Exclude `.ng-animating` nodes
// (matching the `before` capture) so we never target a task that's
// still running its enter animation at `height: 0`.
const firstTask = section.locator('task:not(.ng-animating)').nth(0);
const lastTask = section.locator('task:not(.ng-animating)').nth(2);
await cdkDragTo(page, firstTask.locator('done-toggle').first(), lastTask);
await expect
.poll(async () => (await sectionTaskTitles())[0], { timeout: 5000 })
.not.toEqual(before[0]);
// And all three tasks are still in the section.
await expect.poll(async () => (await sectionTaskTitles()).length).toBe(3);
});
test('moves a task from one section to another', async ({
page,
workViewPage,
projectPage,
}) => {
await setupTestProject(workViewPage, projectPage);
await disableAnimations(page);
await openProjectContextMenu(page);
await clickAddSection(page);
await submitPromptDialog(page, 'Left');
await openProjectContextMenu(page);
await clickAddSection(page);
await submitPromptDialog(page, 'Right');
const left = sectionByTitle(page, 'Left');
const right = sectionByTitle(page, 'Right');
await expect(left).toBeVisible();
await expect(right).toBeVisible();
// Populate Right with [Xray, Yankee] and Left with [Zulu].
for (const [name, target] of [
['Xray', right],
['Yankee', right],
['Zulu', left],
] as const) {
await workViewPage.addTask(name);
const t = page.locator('.no-section task').filter({ hasText: name }).first();
await cdkDragTo(
page,
t.locator('done-toggle').first(),
target.locator('task-list').first(),
);
await expect(target.locator('task').filter({ hasText: name })).toBeVisible({
timeout: 5000,
});
}
// Drag Zulu into Right's actual CDK drop list. Targeting a task row can
// race the row transform while CDK makes room for the incoming item.
const taskZulu = left.locator('task').filter({ hasText: 'Zulu' }).first();
await cdkDragTo(
page,
taskZulu.locator('done-toggle').first(),
right.locator('.task-list-inner').first(),
);
// Behavioral invariant: Zulu has crossed sections. Exact slot is
// CDK-cursor dependent, so we don't pin it.
await expect(right.locator('task').filter({ hasText: 'Zulu' })).toBeVisible({
timeout: 5000,
});
await expect(left.locator('task').filter({ hasText: 'Zulu' })).toHaveCount(0);
await expect(right.locator('task')).toHaveCount(3);
});
test('drags a task back out of a section into the main list', async ({
page,
workViewPage,
projectPage,
}) => {
await setupTestProject(workViewPage, projectPage);
await workViewPage.addTask('Roundtrip');
await page.waitForSelector('task', { state: 'visible' });
await openProjectContextMenu(page);
await clickAddSection(page);
await submitPromptDialog(page, 'Holding');
const section = sectionByTitle(page, 'Holding');
const sectionTaskList = section.locator('task-list').first();
const noSection = page.locator('.no-section').first();
const task = page.locator('task').filter({ hasText: 'Roundtrip' }).first();
let dragHandle = task.locator('done-toggle').first();
// Move into section.
await cdkDragTo(page, dragHandle, sectionTaskList);
const taskInSection = section
.locator('task')
.filter({ hasText: 'Roundtrip' })
.first();
await expect(taskInSection).toBeVisible();
// Give the no-section CDK list a stable row target. Its empty-state text
// is a sibling that overlaps the otherwise-empty drop rect, so a row is
// the deterministic equivalent of a user dropping beside another task.
await workViewPage.addTask('Main anchor');
const mainAnchor = noSection.locator('task').filter({ hasText: 'Main anchor' });
await expect(mainAnchor).toBeVisible();
// Move back out — re-acquire handle from the new DOM location.
dragHandle = taskInSection.locator('done-toggle').first();
await cdkDragTo(page, dragHandle, mainAnchor);
await expect(noSection.locator('task').filter({ hasText: 'Roundtrip' })).toBeVisible({
timeout: 5000,
});
await expect(section.locator('task').filter({ hasText: 'Roundtrip' })).toHaveCount(0);
});
test('sections persist across a page reload', async ({
page,
workViewPage,
projectPage,
}) => {
await setupTestProject(workViewPage, projectPage);
await openProjectContextMenu(page);
await clickAddSection(page);
await submitPromptDialog(page, 'Persistent');
await expect(sectionByTitle(page, 'Persistent')).toBeVisible();
// Reload — the project view re-hydrates from IndexedDB.
await page.reload();
await workViewPage.waitForTaskList();
// The reload may land on Today; navigate back to the project so the
// section list is what's rendered.
await projectPage.navigateToProjectByName('Test Project');
await expect(sectionByTitle(page, 'Persistent')).toBeVisible({ timeout: 10000 });
});
});