* feat(sections): Introduce core section state, model, and persistence
* feat(sections): Implement SectionService and link sectionId to Task model
* feat(sections): Add 'Add Section' functionality to project context menu
* feat(sections): Implement cascading task deletion for sections
* feat(sections): Enable drag & drop for tasks into sections
* feat(sections): Display, manage, and reorder sections in Work View
* refactor(tasks): Add guard for invalid subtask moves in reducer
* feat(markdown): Add interfaces for markdown sections
* feat(markdown): Implement markdown section parsing utility
* feat(section): Add helper methods to SectionService
* feat(markdown): Add i18n for markdown section paste confirmation
* refactor(markdown): Prepare MarkdownPasteService for section support
* feat(markdown): Implement markdown section paste handling in MarkdownPasteService
* feat(markdown): Update paste detection to include markdown sections
* test(markdown): Add simple test for markdown section parsing utility
* fix(sections): post-merge type errors and stray file cleanup
- model-config.ts: drop unsupported `validate` field on section ModelCfg;
validators are looked up via validateAppDataProperty(key, data) instead.
- app-data-mock.ts: add `section: createEmptyEntity()` so AppDataComplete is satisfied.
- Remove test-sections.js (development artifact from the original PR).
* refactor(sections): atomize section deletion via meta-reducer
Replace the dispatch-chained section.effects.ts with a section-shared
meta-reducer that removes the section entity and cascade-deletes its
tasks (and subtasks) plus their references in projects and tags in a
single reducer pass.
Why:
- The previous effect used inject(Actions), so it would re-fire during
remote sync replay and double-delete tasks.
- It dispatched a separate deleteTasks action, producing two operations
in the sync log; a partial sync between them could leave a tree of
orphaned tasks pointing at a vanished section.
Changes:
- Add src/app/root-store/meta/task-shared-meta-reducers/section-shared.reducer.ts
- Register sectionSharedMetaReducer in Phase 5 of meta-reducer-registry.ts
- Remove src/app/features/section/store/section.effects.ts and its
EffectsModule.forFeature registration in feature-stores.module.ts
* refactor(sections): visual layer model + tag/today support
Make sections a pure visual grouping (analogous to boards) rather than a
property of tasks. This eliminates the cross-entity coupling that drove
most of the original PR's sync-correctness issues.
Section model:
- Drop Task.sectionId entirely. Sections own their membership via
Section.taskIds: string[].
- Generalize Section.projectId → contextId + contextType ('PROJECT' | 'TAG').
This unblocks sections in tag and TODAY views.
- New addTaskToSection action atomically removes the task from any other
section and inserts it into the target at the requested position via
adapter.updateMany — one reducer pass, one sync operation. Replaces the
old project.effects moveProjectTaskToSection effect, which dispatched
two persistent actions and was non-atomic.
Section deletion:
- Pure entity removal; no task cascade. Tasks in the deleted section
simply become "no section" tasks (DELETE_SECTION confirmation reflects
this; the now-obsolete DELETE_SECTION_CASCADE string is removed).
Section-shared meta-reducer (Phase 5):
- Repurposed from the previous deleteSection cascade handler. Now listens
for TaskSharedActions.deleteTask/deleteTasks and prunes deleted task IDs
from any section.taskIds, keeping section state consistent without
requiring an effect.
Selector / perf:
- selectSectionsByContextId is backed by a single memoized
selectSectionsByContextIdMap. Replaces the per-call factory pattern that
broke memoization across consumers.
- WorkViewComponent.undoneTasksBySection now indexes section→task in
O(n + m) using a Map keyed by taskId.
WorkContextMenu:
- Drop the @if (isForProject) gate on Add Section. The menu now offers it
for tags (incl. TODAY) and propagates the appropriate contextType.
Cleanup:
- Add takeUntilDestroyed to all dialog .subscribe() chains in
work-view.component.ts and work-context-menu.component.ts.
- Drop unused MatMenu/MatMenuTrigger/MatMenuItem imports.
- Yield the event loop every 10 sections during markdown imports.
- Revert the es.json edits added by the original PR (en-only policy).
* test(sections): cover reducer + meta-reducer behavior
- sectionReducer: addSection (default empty taskIds), deleteSection
(entity-only, no cascade), updateSection partial changes,
updateSectionOrder scoped to one context, addTaskToSection (anchor
positioning, uniqueness across sections, intra-section moves, ungrouping
on null sectionId, no-op when target missing).
- sectionSharedMetaReducer: deleteTask removes the id from any section,
cascades through subtasks, deleteTasks bulk variant, passes through
unrelated actions unchanged.
* fix(sections): wire new entity through suite-wide invariants
Fixes 11 unit-test failures introduced by adding the section model:
- ActionType enum gains SECTION_ADD_TASK (the new atomic move action)
and a corresponding ACTION_TYPE_TO_CODE entry; spec member-count
expectation bumps from 136 → 141 to match.
- extractEntityKeysFromState now emits SECTION:<id> keys so
OperationLogCompactionService recognizes section as a valid model.
- task-list enterPredicate distinguishes section drop-lists from
subtask drop-lists via drop.data.listId. Parent tasks may drop
into PARENT_ALLOWED_LISTS or any parent-level (section) drop-list,
but never into a subtask drop-list (listId === 'SUB').
- Add section: { ids: [], entities: {} } to the empty-state helper in
validate-state.service.spec; without it Typia validation fails
because section is now a required slice of AppDataComplete.
- Add activeWorkContextId$: of(null) to the WorkContextService mock
in work-view.component.spec; the section signal subscribes to that
observable in the constructor.
- Add 'section' → 'SECTION' to the modelToEntityType map in
operation-log-compaction.service.spec.
Five immediate-upload.service.spec.ts failures remain; they reproduce
on master with identical files, so they're unrelated to this branch.
* fix(sections): apply multi-review C1-C4 + harden taskIds reads
C1: Move sectionSharedMetaReducer before taskSharedCrudMetaReducer.
At Phase 5 it ran AFTER the task entity was already removed, so
state.task.entities[id]?.subTaskIds was always undefined and section
references to deleted subtasks leaked. The section spec passed only
because its mock reducer didn't apply the cascade.
C2: Tighten task-list enterPredicate. Subtasks may now drop only into
another subtask drop-list (listId === 'SUB') or appear as top-level
in DONE/UNDONE. Section drop-lists (listId === 'PARENT' with a section
id) reject subtask drags so section.taskIds stays parent-only.
C3: Add cdkDropListEnterPredicate to the sections-wrapper. Without it,
a task drag could land on the section-reorder list and dropSection
would treat a Task as a Section, corrupting ordering. The new
acceptSectionDragOnly predicate brand-checks the drag's data.
C4 + cleanup: drop the unused Section.isCollapsed field; remove the
redundant `// 1.` `// 2.` comments in section.reducer; tighten the
`(state as any).section` cast (AppStateSnapshot already declares the
field); fix the misleading meta-reducer-registry comment.
Plus a reported runtime crash: cleanupSectionTaskIds threw on
undefined `s.taskIds` for sections persisted before this branch added
the field. Defensive `?? []` everywhere that reads `taskIds`, and a
normalizeLoadedSections pass on `loadAllData` so old persisted state
is brought up to the current shape.
* fix(sections): Add Section button — drop takeUntilDestroyed on dialog sub
WorkContextMenuComponent lives inside a <mat-menu>. The menu (and the
component) is destroyed the moment the dialog opens, so
takeUntilDestroyed(this._destroyRef) tore down the afterClosed()
subscription before it could ever emit the typed title. Result: the
dialog appeared, the user typed, clicked Save — but no section was
ever dispatched.
MatDialog cleans up its own subscription once the dialog closes, so
the explicit teardown is unnecessary here. WorkViewComponent's similar
dialog subscriptions are unaffected (that component is not inside a
mat-menu).
* style(sections): move Add Section above Settings in context menu
* fix(sections): clean up orphan sections on project/tag deletion
Closes the cleanup gap surfaced by user audit: until now, deleting a
project or tag left behind its sections — they kept their stale
contextId and contextType but no longer matched any context, piling up
in state and the sync log. Section-level cleanup was only wired for
task deletion.
sectionSharedMetaReducer now also handles:
- TaskSharedActions.deleteProject → remove sections where
contextType='PROJECT' and contextId === projectId.
- deleteTag (single) → remove sections where contextType='TAG' and
contextId === id.
- deleteTags (bulk) → remove sections where contextType='TAG' and
contextId is in ids.
contextType is matched explicitly so a project and a tag that happen
to share the same id (theoretical, but possible) don't cross-pollute.
Added 3 specs covering each path, including the same-id collision case.
* fix(sections): clean section.taskIds on task move + tag removal
Two more cleanup paths surfaced by the user audit:
1. Task moves to another project (TaskSharedActions.moveToOtherProject):
the moved task and its subtasks were left lingering in any section
owned by the previous project. Now the meta-reducer reads the task's
old projectId from state (action runs before taskSharedCrud) and
strips the task ids from project-scoped sections in that project.
Tag-scoped sections are intentionally untouched because tag
membership doesn't change on a project move.
2. Task tagIds change (TaskSharedActions.updateTask): when a tag is
removed from a task, any section owned by that tag still kept the
task id. Now we diff oldTagIds vs new tagIds, and for each removed
tag, strip the task id from sections matching contextType='TAG' and
contextId === removedTagId. Tag additions are not auto-joined to a
section — that stays a user action.
The new cleanupTaskIdsInContexts helper does both jobs (restricting
edits to a specific contextType + a set of contextIds), keeping the
logic narrow.
Specs cover: project move strips only old-project sections (and
subtasks), tag removal strips only the dropped tags' sections, and
updateTask without a tagIds change is a no-op.
* refactor(sections): replace SectionContextType with WorkContextType
Drop the parallel `SectionContextType = 'PROJECT' | 'TAG'` union in
favour of the existing `WorkContextType` enum, which has identical
string values. Same on-disk shape (the Typia validator's literal-string
serialization is unchanged), but the model now reuses the canonical
work-context taxonomy and consumers can pass `activeWorkContextType`
straight through without translation ternaries.
Touches: section.model + section.service signature, the section-shared
meta-reducer's helpers and handlers (`WorkContextType.PROJECT/TAG`
instead of literals), the work-context-menu / work-view / markdown-paste
call sites (drop the `=== WorkContextType.PROJECT ? 'PROJECT' : 'TAG'`
ternary), and the spec fixtures.
* refactor(sections): split addTaskToSection — drop the 'NONE' sentinel
The 'NONE' string sentinel for ungrouped op-log entries had two
problems: (1) two clients ungrouping different tasks concurrently
both wrote ops keyed on entityId='NONE', so LWW conflict resolution
collapsed them and one ungroup was discarded; (2) magic string with
no constant or test pinning it.
Split into two persistent actions:
- addTaskToSection({ sectionId, taskId, afterTaskId? }) — sectionId
is now non-nullable; entityId tracks the destination section. The
reducer still atomically strips the task from any other section
(uniqueness invariant), so a single op covers a full move.
- removeTaskFromSection({ sectionId, taskId }) — entityId is the
source section. Concurrent ungroups from different sources are now
independent ops with distinct entityIds; LWW conflict resolution
treats them correctly.
The drag-drop call site already knows both source and target list ids
(srcIsSection / targetIsSection), so the SectionService just exposes
two narrow methods: `addTaskToSection(target, ...)` and
`removeTaskFromSection(source, ...)`. The work-view caller uses
addTaskToSection only (markdown paste never ungroups).
Two new specs cover removeTaskFromSection: strips only the named
section, no-op when the task isn't there, no-op when the section is
missing.
* fix(sections): apply round-2 review batch
Addresses ten findings from the second multi-review pass.
Security
- Replace plain-object grouping caches with Map<string, …> so a
malicious sync peer can't poison Object.prototype via crafted
contextIds like '__proto__'. Affects selectSectionsByContextIdMap;
the per-call factory selector is removed (see Performance below).
Correctness
- normalizeLoadedSections defends against state.ids === undefined
(very old persisted shapes wrote `section: {}`).
- Section meta-reducer now also handles TaskSharedActions.updateTasks
(bulk). Previously only updateTask fired the tag-removal cleanup;
a future bulk dispatcher mutating tagIds would silently leak orphan
task ids in tag-context sections. Spec covers it.
Architecture
- Phase 3.5 promoted to a named phase in the registry doc block.
- validateMetaReducerOrdering pins sectionSharedMetaReducer to run
before taskSharedCrudMetaReducer; any future reorder fails
dev-mode validation. The handlers' pre-CRUD state read is now
documented at the top of section-shared.reducer.ts.
Alternatives + Performance
- Drop the per-call selectSectionsByContextId factory. The service
selects selectSectionsByContextIdMap and pipes map(...) — no fresh
MemoizedSelector per call, no leak.
- addTaskToSection reducer breaks out of the uniqueness scan after
the first removal (a task is in at most one section at a time).
- Pre-filter sectionSharedMetaReducer on a static
HANDLED_ACTION_TYPES Set so the 99% of dispatches that don't match
short-circuit before allocating the handler dispatch table.
Simplicity
- Collapse SectionService.{addSection, addSectionWithId,
generateSectionId} into a single addSection that returns the new
id synchronously. Markdown-paste consumes it directly.
- Remove collectTaskAndSubtaskIds (single-task path is just
collectAffectedTaskIds with a one-element array).
- dropSection drops the object spread + extra map; reorder ids in
place.
- Tighten ExtendedState — SECTION_FEATURE_NAME is always registered,
so the optional-typing dance and the four `if (\!sectionState)`
early-returns inside helpers are removed.
* fix(sections): apply round-3 review batch
Fixes from a multi-reviewer pass (codex + claude + code-reviewer
sub-agent) on the sections feature.
Critical
- task-list: subtask-to-subtask drag was being misidentified as a
section move. _move() now takes srcListId/targetListId and only
treats a non-reserved listModelId as a section when listId is
PARENT. Subtask drop-lists ('SUB') fall through to moveSubTask.
- parse-markdown-tasks: tasks before the first markdown header were
silently dropped by parseMarkdownWithSections. They now flush into
a top-of-list "No Section" entry. Also broaden the header regex
from /^#\s+/ to /^#{1,6}\s+/ so H2-H6 are recognized.
- section.actions/reducer/service: addTaskToSection now carries an
explicit sourceSectionId. The reducer strips from that source
rather than searching state, making replay deterministic. Action
meta sets entityIds: [src, dest] when src is provided so vector-
clock conflict detection covers both. Callers in task-list and
markdown-paste pass the actual source (or null for new tasks).
Warnings / cleanups
- section-shared meta-reducer: handle removeTasksFromTodayTag and
localRemoveOverdueFromToday — TODAY is virtual so the existing
tagIds path doesn't catch them. Doc enumerates the residual gap
(scheduling/planner/short-syntax/crud/lww) and proposes a future
Phase 6.5 diff-based meta-reducer for full coverage.
- section-shared reducer: typed as MetaReducer<RootState> /
ActionReducer<RootState, Action> instead of any.
- markdown-paste: yield every 30 dispatches (was: every 10 sections)
per CLAUDE.md rule #11. Type the reduce accumulator as number.
- task-list: extract RESERVED_LIST_IDS constant; comment why it
diverges from PARENT_ALLOWED_LISTS on LATER_TODAY.
- en.json/es.json: restore trailing newlines (es.json now identical
to master).
Tests
- parse-markdown-tasks.spec: 6 cases for parseMarkdownWithSections
(H1, H2/H3, pre-header tasks, empty sections, null input).
- section.reducer.spec: 4 cases for sourceSectionId behavior
(explicit source, null, omitted, intra-section reorder).
- section-shared.reducer.spec: 3 cases for TODAY removal cleanup.
- task-list.component.spec: 5 cases for _move() routing
(subtask vs section disambiguation, source propagation).
* fix(sections): apply round-3 review nits
- task-list: type RESERVED_LIST_IDS values via `satisfies
DropListModelSource[]` so adding a new variant to the union
surfaces a typo here without forcing casts at every `.has()`.
- section.actions: tighten addTaskToSection doc — the `entityIds:
[src, dest]` claim only holds when src is a non-null string
different from dest; intra-section / null fall back to
single-entity meta.
- task-list spec: add two anchor cases for `_move` so
`getAnchorFromDragDrop` is actually exercised on section drops
(previous tests passed `[taskId]` only, which short-circuited to
null).
* fix(sections): apply round-4 review batch
High-severity sync/UX fixes:
- deleteProject now also strips cascaded task ids from tag-context
sections (one extra cleanupSectionTaskIds pass in the meta-reducer);
+ unit test
- Edit Section dialog prefill: val → txtValue
- Markdown paste: drop yieldIfNeeded mid-loop (widened the sync
interleave window); residual atomic-bulk-action gap documented
- Add Section menu item guarded against TODAY_TAG
- Paste hook switched from id-prefix scan to data-task-id attribute
- New section referential validators + auto-repair (data-repair)
- Section view bypasses customizer when filter/sort active
- Parser hardening: input cap, CRLF/BOM normalization, reduce-based
min-indent (avoids RangeError on huge spreads), JSDoc fix
- moveSubTask guard now also rejects self-moves; console.warn →
TaskLog.warn
- Work-view styling: use --s/--s3 tokens, narrow \!important rules
- Drop dead loadSections action + SectionService.sections$
- Trailing-space prettier nits in sync.model.ts + feature-stores.module
* fix(sections): apply multi-review batch — moveToArchive + cleanup
- moveToArchive: add handler in section-shared meta-reducer so archived
tasks (and their subtasks) no longer leak as stale ids in
section.taskIds until dataRepair clears them.
- Collapse the dual HANDLED_ACTION_TYPES + handlers map into a single
ACTION_HANDLERS record; the prior split is what allowed moveToArchive
to drift out of sync.
- Make addTaskToSection's sourceSectionId required (string | null) and
delete the legacy defensive sweep branch in the reducer.
- updateSectionOrder now keeps other-context sections in their slot in
state.ids instead of pushing them to the front.
- Sanitize section titles (trim + 200-char cap) on add/update.
- Remove dead addSection() in work-view.component.ts (template only
wires editSection/deleteSection; addSection lives on work-context-menu).
- Remove dead translation keys WW.ADD_SECTION and WW.ADD_SOME_TASKS.
- Drop hasHeaders field from MarkdownWithSections (null return already
encodes header-less input).
- Drop normalizeLoadedSections + ?? [] guards (Section is brand-new, no
legacy persisted shape exists) and unused entity adapter exports.
* perf(sections): apply perf review batch — drop-list coalesce + meta-reducer hot paths
- DropListService: coalesce burst register/unregister calls via a
microtask flush. Mounting 50 sections previously emitted 50 times
through the BehaviorSubject; cdkDropListConnectedTo rebuilds its
sibling graph per emission, so first paint cost was ~O(L²). Now
one downstream emission per CD pass.
- section-shared meta-reducer: replace Object.values(entities) sweeps
with `for-of state.ids` (no intermediate array alloc on every matched
task action) and collapse `.some + .filter` double-walk into a single
pass that returns null when nothing changed. Halves walk count on
affected sections under op-log replay.
- updateTasks handler: aggregate (taskId → removedTagIds) across the
batch into a `tagId → tasksToRemove` map, then sweep candidate
sections once with a single adapter.updateMany. Drops a 50-task
batch from O(N·S) to O(N + S).
- section.service: stable EMPTY_SECTIONS constant for contexts with no
sections, so OnPush downstream isn't broken by a fresh `[]` per
emission.
- work-view template: drop `|| []` on dict[section.id] — the dict is
pre-populated per section, so the fallback was allocating a fresh
array reference per CD pass for every empty section.
- acceptSectionDragOnly: bind [cdkDragData]="section" and reduce the
predicate to one property read; was running shape-validation on
every dragOver tick.
- markdown paste: cheap regex pre-screen in isMarkdownTaskList so
plain-text Ctrl+V pastes bail before invoking the parser, and
memoise the sectioned-parse result by string reference so the
immediately-following handleMarkdownPaste doesn't re-parse.
* feat(sections): project-only Add Section + empty main-list hint
- work-context-menu: tighten Add Section gating from
`isForProject || contextId \!== TODAY_TAG_ID` to `isForProject`. The
menu item is now hidden for the today list and for tag contexts in
general; sections in tag contexts can still arrive via markdown paste.
- work-view: when the sectioned view's no-section bucket is empty
(every task lives in a section), render a small italic hint
("All tasks are organized into sections") instead of leaving the
area silently empty.
* fix(sections): keep no-section drop target alive when empty
Previously the no-section area swapped task-list for a <p> hint when
empty, removing the cdkDropList. That blocked drops from sections back
into the main list. Restore the always-rendered task-list and pass the
hint via its built-in [noTasksMsg]:
- task-list keeps its drop target so cross-section moves work.
- The hint is muted via task-list's existing .no-tasks styling
(var(--text-color-muted)).
- The hint is hidden when undoneTasks() is empty so it doesn't
duplicate the horizon "no tasks planned" empty state.
* feat(sections): show Add Section in every work-context menu
Drop the gate around the Add Section menu item so it shows for
projects, regular tags, and the today list. Sections in TODAY are
stored as TAG-context sections under the TODAY tag id, matching the
existing tag-section flow.
* fix(sections): apply round-6 multi-review batch
Critical:
- enterPredicate rejects backlog → section drops; previous path left
the task in both backlog and section.taskIds.
- editSection / addSection trim whitespace before the truthy check
(sanitizeSectionTitle would otherwise produce empty titles).
- TODAY_TAG cleanup: post-reducer diff in section-shared meta-reducer
catches every flow that removes ids from TODAY (scheduling, planner,
short-syntax, undo, lww), closing the documented residual gap. Bulk
action handlers retained — handler + diff are idempotent.
Sync-replay defense:
- updateSectionOrder accepts partial / out-of-date payloads via dedup +
append-missing instead of a fragile cursor walk; new tests cover
shorter and stale payloads.
- sanitizeSectionTitle moved to section.model and applied inside the
reducer for addSection / updateSection so remote ops can't bypass
the 200-char cap.
Cleanup:
- markdown-paste memo moved from module scope to instance state, with
explicit clear after consumption (clipboard content no longer
retained for the rest of the session).
- undoneTasksBySection bound once via @let in the work-view template.
- Factory selector selectSectionsForContext(contextId) replaces the
whole-map mapping in SectionService.
- Drop log-only validateSections (data-repair already cleans).
Follow-ups documented inline (not in this PR):
- task.sectionId membership model (W4)
- removeTaskFromSection → addTaskToSection consolidation (W3)
- marked-based parseMarkdownWithSections (S1)
* fix(sections): guard TODAY-tag diff against undefined state slices
`diffRemovedTodayTaskIds`, `applyTodayTagSectionCleanup`, and
`collectAffectedTaskIds` all assumed their slices existed. During
early boot / undo / hydration paths the tag, task, or section slice
can be undefined, causing the meta-reducer to crash on
`prevTagState.entities[...]`. The crash cascaded into selector
errors (idleTime, activeType, isShowAddTaskBar) because subsequent
reducer passes never ran.
Bail out early when slices are absent — there's nothing to clean up
yet.
* test(sections): add e2e coverage for basic section flows
Covers the core user-visible section behavior:
- create section via project header context menu
- reject whitespace-only titles (verifies the C2 trim fix)
- edit section title via the per-section menu
- delete section after dialog confirmation
- drag a task into a section (Angular CDK drag-drop helper, since
Playwright's `dragTo` uses HTML5 drag events that CDK ignores)
- sections persist across a page reload (IndexedDB hydration)
The reverse drag (section → no-section bucket) is fixme'd: forward
drag passes, but the reverse target's bounding box collapses to a
hint message when empty and the CDK drop won't register reliably
in headless. Reducer-level coverage in section.reducer.spec.ts
(`removeTaskFromSection`) substitutes for now.
* fix(sections): apply round-7 multi-review batch
Hardening:
- Reducer-side `sanitizeSectionTitle` now coerces non-string input
(null / undefined) to "" instead of throwing. A malformed remote op
(`addSection({ section: { title: undefined } })`) was the threat
model the cap was added for; previously it crashed the reducer.
- `updateSection` now sanitizes on key-presence (`'title' in changes`)
rather than `typeof === 'string'` so a peer shipping `title: null`
cannot bypass the cap and corrupt the entity's typed contract.
- Single dispatcher-level boot guard in `sectionSharedMetaReducer`
catches every action handler that touches task / tag / section
slices, replacing the asymmetric per-function guards from round 6.
Cleanup:
- Revert the round-6 `selectSectionsForContext` factory selector. It
allocated a fresh `MemoizedSelector` per `getSectionsByContextId$`
call, undermining the memoization the simplification claimed. The
service's previous `.pipe(map(m => m.get(id) ?? EMPTY))` shape is
one fewer layer with identical behavior — single consumer, no win.
- Move `sanitizeSectionTitle` + `MAX_SECTION_TITLE_LENGTH` to
`section.utils.ts` (model files in this repo are pure type files).
- Inline `_clearSectionsCache()` (single caller).
- Drop the redundant 5-step settle-move at the end of the e2e
`cdkDragTo` helper.
Tests:
- Pin the actual ordering in the `updateSectionOrder` partial-payload
test (was only checking dedupe count, missed the explicit shape).
- New: empty-string title passes through (legitimate clear).
- New: null/undefined title is coerced, not stored as null.
- New: malformed remote op with undefined title doesn't crash addSection.
- Replace `waitForTimeout(500)` in the e2e whitespace-rejection test
with a polling `toHaveCount(0)` assertion (project rule violation).
- Strengthen the `updateSection` cap test with leading-whitespace input.
Deferred (tracked as follow-ups):
- W2: explicit `handleRemoveFromTodayTag` redundant with the diff —
keep both for now; soak the diff before unifying.
* test(sections): drop unused eslint-disable directives
Use `as unknown as string` casts instead of `as any` + eslint-disable
in the malformed-input reducer tests. Same intent (force the type
assertion to model what a malicious peer's payload looks like at
runtime), no lint suppression needed.
* style(sections): drop redundant message from Add Section dialog
The Add Section dialog passed both `placeholder: T.G.TITLE` and
`message: T.CONFIRM.ADD_SECTION` to DialogPromptComponent. The
message was redundant — its text ("Add Section") duplicated the
dialog's contextual purpose, and showing it forced the
mat-dialog-content out of `.isNoMsg` mode, adding outer padding
that the Add Tag dialog doesn't have.
Drop the message so the Add Section dialog matches the Add Tag
visual pattern (no outer padding, just the input field). Also
remove the now-unused `T.CONFIRM.ADD_SECTION` translation key.
* fix(sections): apply round-8 multi-review batch
Hardening:
- `sanitizeSectionTitle` swaps `String(title ?? '')` for a
`typeof === 'string'` fast-path. Closes two real defense gaps:
- `String(Symbol())` would throw and crash the reducer.
- `String({toString: () => 'x'.repeat(2**27)})` would materialize
a ~256 MB string before the slice. JSON op-log payloads can
smuggle 2 MB+ literal strings that survive the wire.
Same behavior for the documented `null` / `undefined` cases.
- Reducer + service now share a `hasTitleChange()` helper using
`Object.hasOwn` instead of `'title' in changes`. Strictly better
semantics — `'in'` walks the prototype chain, so a peer payload
with `Object.create({title: 'x'})` would have triggered
sanitization on a key the entity doesn't carry.
- Service-side `updateSection` now uses the same key-presence check
as the reducer (was `typeof === 'string'`); removes a future
reader's "why two checks?" smell.
UX / a11y:
- Add Section dialog now passes `placeholder: T.WW.ADD_SECTION_TITLE`
("Add Section") instead of generic `T.G.TITLE` ("Title"). The
dialog has no title element and no message text, so the
placeholder is the only context users (especially screen-reader
users) get for what they're naming. Mirrors the Add Tag pattern
("Add new Tag" placeholder).
Convention:
- Rename `section.utils.ts` → `section.util.ts`. Repo uses singular
`*.util.ts` (60+ files) vs plural `*.utils.ts` (was 2 outliers).
* feat(sections): Add Section in work-view background context menu
Right-click on the empty area of the work-view (project, tag, or
Today view) now opens a small context menu with one item: "Add
Section". This is a discoverability complement to the side-nav
overflow button — the work-view is where the user is already
focused when they decide they need a section, so the action should
be reachable without navigating back to the side-nav.
Implementation:
- `(contextmenu)` listener on `.task-list-wrapper` calls
`onBgContextMenu`, which skips when the click target is inside
an interactive element (task, button, input, drag handle, …) so
per-element context menus and native form behavior aren't shadowed.
- A hidden `[matMenuTriggerFor]` div is positioned at the cursor
via signals (`bgContextMenuX`, `bgContextMenuY`).
- `addSection()` reuses the same DialogPromptComponent shape as
the side-nav addSection (no `message`, descriptive placeholder),
and reads `activeWorkContextId` / `activeWorkContextType` from
`WorkContextService` so it works across project / tag / Today.
E2E test: right-click → menu item → submit dialog → assert the
section is rendered.
* fix(sections): initialize section slice in dataRepair for legacy migrations
Legacy 'pf' databases predate the sections feature and don't carry
a section field. After Typia validation rejects the missing field,
`dataRepair` was supposed to fix it — but `_repairSections` early-
returned on missing state instead of initializing it, so re-validation
failed and `OperationLogMigrationService._performMigration` aborted
with "Migration failed."
Match the existing init pattern (archiveYoung, archiveOld, reminders):
populate `dataOut.section = { ids: [], entities: {} }` if absent
before per-slice repair runs.
Verified by running e2e/tests/migration/legacy-data-migration.spec.ts.
* fix(sections): apply round-9 thorough review batch
Critical:
- Reject section → BACKLOG drag in `enterPredicate`. The handler
was removing the task from `section.taskIds` but never dispatching
`moveProjectTaskToBacklogList`, so the task vanished from the
section without appearing in the backlog. Mirrors the existing
BACKLOG → section guard.
Warnings:
- `section.reducer.ts: loadAllData` now falls back to
`initialSectionState` when the payload omits `section`. Previous
behavior preserved stale local state, violating SYNC_IMPORT /
BACKUP_IMPORT semantics ("complete fresh start").
- `markdown-paste.service.ts` checks `sectionTitle?.trim()` before
calling `addSection`, so headers like `## ` (zero-width space)
fall through to the noSection bucket instead of creating a
titleless section.
- `moveToArchive` section cleanup unions payload subtasks with the
state-derived expansion. Robust under both threat models: replay
where the parent entity is gone from state (payload carries the
tree) AND callers passing `subTasks: []` (state lookup is the
only signal).
Suggestions:
- Section overflow button gets an `aria-label="Section options"`
(new `T.WW.SECTION_OPTIONS` translation key).
- `_repairSections` now does `Array.isArray(taskIds)` instead of
`?? []` — defends against malformed remote payloads where
`taskIds` is a truthy non-array value.
- `acceptSectionDragOnly` discriminates on `'contextType' in data`
rather than `Array.isArray(data.taskIds)`. The latter would
silently accept Task drags if Task ever gained a `taskIds` field.
Documentation:
- Added LWW conflict-resolution gap to the section-shared
meta-reducer's KNOWN FOLLOW-UPs block. Project- and non-TODAY-tag-
context section.taskIds can leak phantom references after LWW
resolves a tagIds / projectId update; the diff catches TODAY but
not other contexts. Visible impact bounded by render-time
intersection in `undoneTasksBySection`. Cleaned by next
`dataRepair` pass.
False positive: `extract-entity-keys.ts` already guards via
`entityState?.ids` optional chain — no change needed.
Verified: 24 reducer tests, 17 meta-reducer tests, 51 data-repair
tests, 35 task-list tests, 7 work-view tests, 7 active section
e2e tests + migration e2e all pass.
* refactor(sections): simplification pass — drop micro-helpers + comment cleanup
A focused simplification review of the branch found small wins
that the correctness-focused rounds had accreted around.
Removed:
- `hasTitleChange()` helper. Single-line `Object.hasOwn(c, 'title')`
is clearer at the two call sites than a 3-line wrapper.
- `_parseSectionsCached` instance memo + private fields in
`MarkdownPasteService`. The `MARKDOWN_TASK_OR_HEADER_RE` pre-screen
already gates plain-text pastes; for genuine sectioned input the
parser walks once cheaply and the dialog wait dwarfs parse time.
Removes mutable instance state and the `MarkdownWithSections` type
import that only existed for the cache.
Comment trimming (74 LOC removed across 5 sites, no behavior change):
- `addTaskToSection`: 22 → 8 lines (the architectural follow-up was
duplicated elsewhere; kept the essential meta-shape note).
- `Phase 3.5 placement` block: 11 → 3 lines (runtime
`validateMetaReducerOrdering()` already enforces it).
- `moveToArchive` handler: tightened the union-rationale.
- `acceptSectionDragOnly`: 8 → 2 lines.
- `updateSection` reducer: 6 → 2 lines.
- `updateSection` service: dropped redundant explainer.
Things considered and intentionally kept (defended):
- `section.util.ts` — file is clean (one constant + one normalizer);
matches the codebase's per-feature util pattern.
- `section.service.ts` — codebase has a service per feature.
- `EMPTY_SECTIONS` frozen const — a fresh `[]` per emission would
cause downstream computed signals to recompute even for empty
contexts.
- Reducer-side `sanitizeSectionTitle(unknown)` — defends the real
schema-evolution case where a peer ships malformed payloads.
- Boot-guard 3-slice check — needed because the diff path
dereferences tag state.
Verified: 24 reducer tests, 17 meta-reducer tests pass.
* refactor(sections): restrict scope to projects + TODAY tag
Custom-tag sections added per-tag cleanup machinery (deleteTag /
deleteTags handlers, handleTaskTagsChange, handleBulkTaskTagsChange,
removeTasksFromTodayTag/localRemoveOverdueFromToday explicit handlers)
and broadened the validation surface for limited end-user value.
- Add isValidSectionContext helper enforcing PROJECT or TODAY-only.
- Guard at all entry points: SectionService.addSection (returns null),
section.reducer (rejects invalid contextId/Type), data-repair drops
invalid sections on import.
- Hide "Add Section" in custom-tag context menu and suppress the
work-view bg right-click menu there.
- Drop tag-scope handlers from section-shared meta-reducer; rely on
the existing diff-based TODAY_TAG.taskIds path for TODAY cleanup.
- Update unit tests to match.
Net: -243 LOC.
* refactor(sections): drop markdown section paste
The H1-grouped markdown paste path created sections + tasks atomically
from a hand-rolled parser, but the feature is niche, the parser is
duplicated logic next to the existing flat-list and structured paste
paths, and the documented atomicity caveat (partial state on
concurrent sync) was never resolved.
Falls back to the existing flat-list / sub-task paste paths, which
cover the dominant use case.
- Remove parseMarkdownWithSections + MarkdownWithSections /
SectionWithTasks types.
- Drop the section-paste branch and SectionService /
WorkContextService deps from MarkdownPasteService.
- Drop CONFIRM_SECTIONS i18n key.
- Drop spec coverage for the removed parser.
Net: -251 LOC.
* refactor(sections): post-review hardening + cleanups
Apply five findings from the latest cross-reviewer pass.
- loadAllData filters imported sections through isValidSectionContext.
Typia validates structure but not invariants, so a backup from an
older client could otherwise smuggle custom-tag sections back in.
- updateSection rejects payloads that touch contextId/contextType.
No legitimate flow rewrites a section's context — a malformed peer
would otherwise morph a project section into a custom-tag one.
- Add 'section' to ENTITY_STATE_KEYS so _resetEntityIdsFromObjects
reconciles ids ↔ entities like every other entity slice.
- Drop redundant service-side sanitize in updateSection — the reducer
is authoritative.
- Refresh stale addSection JSDoc (markdown-paste reference removed
with the section paste path in
|
||
|---|---|---|
| .. | ||
| constants | ||
| fixtures | ||
| helpers | ||
| pages | ||
| tests | ||
| utils | ||
| .gitignore | ||
| CLAUDE.md | ||
| global-setup.ts | ||
| playwright.config.ts | ||
| README.md | ||
| tsconfig.json | ||
E2E Testing Guide for Super Productivity
This guide provides comprehensive information for writing and maintaining end-to-end tests for Super Productivity using Playwright.
Table of Contents
- Overview
- Running Tests
- Test Structure
- Page Objects
- Common Patterns
- Selectors
- Wait Utilities
- Writing New Tests
- Best Practices
- Troubleshooting
Overview
Our E2E tests are built with Playwright and follow the Page Object Model (POM) pattern for maintainability and reusability. Tests are organized by feature and use shared fixtures for common setup.
Key Technologies
- Playwright: Modern E2E testing framework
- TypeScript: Type-safe test code
- Page Object Model: Encapsulates page interactions
- Fixtures: Shared setup and utilities
Running Tests
Basic Commands
# Run all tests
npm run e2e
# Run tests in UI mode (interactive)
npm run e2e:ui
# Run a single test file with detailed output
npm run e2e:file tests/task-basic/task-crud.spec.ts
# Run tests in headed mode (see browser)
npm run e2e:headed
# Run tests in debug mode
npm run e2e:debug
# Show test report
npm run e2e:show-report
WebDAV Sync Tests
# Run WebDAV tests (starts Docker container)
npm run e2e:webdav
Test Structure
Directory Layout
e2e/
├── constants/ # Shared selectors and constants
│ └── selectors.ts # Centralized CSS selectors
├── fixtures/ # Test fixtures and setup
│ └── test.fixture.ts # Custom test fixtures with page objects
├── helpers/ # Test helper functions
│ └── plugin-test.helpers.ts
├── pages/ # Page Object Models
│ ├── base.page.ts # Base page with common methods
│ ├── work-view.page.ts
│ ├── project.page.ts
│ ├── task.page.ts
│ ├── settings.page.ts
│ ├── dialog.page.ts
│ ├── planner.page.ts
│ ├── schedule.page.ts
│ ├── side-nav.page.ts
│ ├── sync.page.ts
│ ├── tag.page.ts
│ └── note.page.ts
├── tests/ # Test specifications
│ ├── task-basic/
│ ├── project/
│ ├── planner/
│ └── ...
├── utils/ # Utility functions
│ ├── waits.ts # Wait helpers
│ └── sync-helpers.ts
├── playwright.config.ts
└── global-setup.ts
Page Objects
Page Objects encapsulate interactions with specific pages or components. All page objects extend BasePage and receive a page and optional testPrefix.
Available Page Objects
1. BasePage (base.page.ts)
Base class for all page objects. Provides common functionality:
class BasePage {
async addTask(taskName: string): Promise<void>;
// Adds a task with automatic test prefix
}
Example:
await workViewPage.addTask('My Task');
// Creates task with name "W0-P0-My Task" (prefixed for isolation)
2. WorkViewPage (work-view.page.ts)
Interactions with the main work view:
class WorkViewPage extends BasePage {
async waitForTaskList(): Promise<void>;
async addSubTask(task: Locator, subTaskName: string): Promise<void>;
}
Example:
await workViewPage.waitForTaskList();
await workViewPage.addTask('Parent Task');
const task = page.locator('task').first();
await workViewPage.addSubTask(task, 'Child Task');
3. TaskPage (task.page.ts)
Task-specific operations:
class TaskPage extends BasePage {
getTask(index: number): Locator;
getTaskByText(text: string): Locator;
async markTaskAsDone(task: Locator): Promise<void>;
async editTaskTitle(task: Locator, newTitle: string): Promise<void>;
async openTaskDetail(task: Locator): Promise<void>;
async getTaskCount(): Promise<number>;
async isTaskDone(task: Locator): Promise<boolean>;
getDoneTasks(): Locator;
getUndoneTasks(): Locator;
async waitForTaskWithText(text: string): Promise<Locator>;
async taskHasTag(task: Locator, tagName: string): Promise<boolean>;
}
Example:
const task = taskPage.getTask(1); // First task
await taskPage.markTaskAsDone(task);
await expect(taskPage.getDoneTasks()).toHaveCount(1);
4. ProjectPage (project.page.ts)
Project management:
class ProjectPage extends BasePage {
async createProject(projectName: string): Promise<void>;
async navigateToProjectByName(projectName: string): Promise<void>;
async createAndGoToTestProject(): Promise<void>;
async addNote(noteContent: string): Promise<void>;
async archiveDoneTasks(): Promise<void>;
}
Example:
await projectPage.createProject('My Project');
await projectPage.navigateToProjectByName('My Project');
await projectPage.addNote('Project notes here');
5. SettingsPage (settings.page.ts)
Settings and configuration:
class SettingsPage extends BasePage {
async navigateToSettings(): Promise<void>;
async expandSection(sectionSelector: string): Promise<void>;
async expandPluginSection(): Promise<void>;
async navigateToPluginSettings(): Promise<void>;
async enablePlugin(pluginName: string): Promise<boolean>;
async disablePlugin(pluginName: string): Promise<boolean>;
async isPluginEnabled(pluginName: string): Promise<boolean>;
async uploadPlugin(pluginPath: string): Promise<void>;
}
Example:
await settingsPage.navigateToPluginSettings();
await settingsPage.enablePlugin('Test Plugin');
expect(await settingsPage.isPluginEnabled('Test Plugin')).toBeTruthy();
6. DialogPage (dialog.page.ts)
Dialog and modal interactions:
class DialogPage extends BasePage {
async waitForDialog(): Promise<Locator>;
async waitForDialogToClose(): Promise<void>;
async clickDialogButton(buttonText: string): Promise<void>;
async clickSaveButton(): Promise<void>;
async fillDialogInput(selector: string, value: string): Promise<void>;
async fillMarkdownDialog(content: string): Promise<void>;
async saveMarkdownDialog(): Promise<void>;
async editDateTime(dateValue?: string, timeValue?: string): Promise<void>;
}
Example:
await dialogPage.waitForDialog();
await dialogPage.fillDialogInput('input[name="title"]', 'New Title');
await dialogPage.clickSaveButton();
await dialogPage.waitForDialogToClose();
Common Patterns
Pattern 1: Basic Task CRUD
test('should create and edit task', async ({ page, workViewPage, taskPage }) => {
await workViewPage.waitForTaskList();
// Create
await workViewPage.addTask('Test Task');
await expect(taskPage.getAllTasks()).toHaveCount(1);
// Edit
const task = taskPage.getTask(1);
await taskPage.editTaskTitle(task, 'Updated Task');
await expect(taskPage.getTaskTitle(task)).toContainText('Updated Task');
// Mark as done
await taskPage.markTaskAsDone(task);
await expect(taskPage.getDoneTasks()).toHaveCount(1);
});
Pattern 2: Project Workflow
test('should create project and add tasks', async ({ projectPage, workViewPage }) => {
await projectPage.createAndGoToTestProject();
await workViewPage.addTask('Project Task 1');
await workViewPage.addTask('Project Task 2');
await expect(page.locator('task')).toHaveCount(2);
});
Pattern 3: Settings Configuration
test('should enable plugin', async ({ settingsPage, waitForNav }) => {
await settingsPage.navigateToPluginSettings();
await settingsPage.enablePlugin('My Plugin');
await waitForNav();
expect(await settingsPage.isPluginEnabled('My Plugin')).toBeTruthy();
});
Pattern 4: Dialog Interactions
test('should edit date in dialog', async ({ taskPage, dialogPage }) => {
const task = taskPage.getTask(1);
await taskPage.openTaskDetail(task);
const dateInfo = dialogPage.getDateInfo('Created');
await dateInfo.click();
await dialogPage.editDateTime('12/25/2025', undefined);
await dialogPage.clickSaveButton();
});
Selectors
All selectors are centralized in constants/selectors.ts. Always use these constants instead of hardcoding selectors in tests.
Using Selectors
import { cssSelectors } from '../constants/selectors';
const { TASK, TASK_TITLE, TASK_DONE_BTN } = cssSelectors;
// In test:
const task = page.locator(TASK).first();
const title = task.locator(TASK_TITLE);
Selector Categories
- Navigation:
SIDENAV,NAV_ITEM,SETTINGS_BTN - Layout:
ROUTE_WRAPPER,BACKDROP,PAGE_TITLE - Tasks:
TASK,TASK_TITLE,TASK_DONE_BTN,SUB_TASK - Add Task:
ADD_TASK_INPUT,ADD_TASK_SUBMIT - Dialogs:
MAT_DIALOG,DIALOG_FULLSCREEN_MARKDOWN - Settings:
PAGE_SETTINGS,PLUGIN_SECTION,PLUGIN_MANAGEMENT - Projects:
PAGE_PROJECT,CREATE_PROJECT_BTN,WORK_CONTEXT_MENU
Wait Utilities
Located in utils/waits.ts, these utilities help handle Angular's async nature.
Available Wait Functions
waitForAngularStability(page, timeout?)
Waits for Angular to finish all async operations.
await waitForAngularStability(page);
waitForAppReady(page, options?)
Comprehensive wait for app initialization.
await waitForAppReady(page, {
selector: 'task-list',
ensureRoute: true,
routeRegex: /#\/project\/\w+/,
});
waitForStatePersistence(page)
Waits for IndexedDB persistence to complete (important before sync operations).
await workViewPage.addTask('Task');
await waitForStatePersistence(page); // Ensure saved to IndexedDB
// Now safe to trigger sync
Writing New Tests
Step 1: Create Test File
// e2e/tests/my-feature/my-feature.spec.ts
import { test, expect } from '../../fixtures/test.fixture';
test.describe('My Feature', () => {
test('should do something', async ({ page, workViewPage, taskPage }) => {
// Test code here
});
});
Step 2: Use Page Objects
test('my test', async ({ workViewPage, taskPage, dialogPage }) => {
// Wait for page ready
await workViewPage.waitForTaskList();
// Use page objects for interactions
await workViewPage.addTask('Task 1');
const task = taskPage.getTask(1);
await taskPage.markTaskAsDone(task);
// Assertions
await expect(taskPage.getDoneTasks()).toHaveCount(1);
});
Step 3: Handle Waits Properly
// GOOD: Use Angular stability waits
await workViewPage.addTask('Task');
await waitForAngularStability(page);
await expect(page.locator('task')).toBeVisible();
// BAD: Arbitrary timeouts
await page.waitForTimeout(5000); // Avoid unless necessary
Step 4: Use Selectors from Constants
import { cssSelectors } from '../../constants/selectors';
const { TASK, TASK_TITLE } = cssSelectors;
const title = page.locator(TASK).first().locator(TASK_TITLE);
Best Practices
✅ DO
- Use page objects for all interactions
- Use centralized selectors from
constants/selectors.ts - Wait for Angular stability after state changes
- Use test prefixes (automatic via fixtures) for isolation
- Test one thing per test - keep tests focused
- Use descriptive test names - "should create task and mark as done"
- Clean up state - tests should be independent
- Use role-based selectors when possible (accessibility)
// GOOD
await page.getByRole('button', { name: 'Save' }).click();
// LESS GOOD
await page.locator('.save-btn').click();
❌ DON'T
- Don't hardcode selectors - use
cssSelectors - Don't use arbitrary waits - use
waitForAngularStability - Don't share state between tests - each test should be independent
- Don't access DOM directly - use page objects
- Don't skip error handling - tests should fail clearly
- Don't use
anytypes - maintain type safety
Test Isolation
Each test gets:
- Isolated browser context (clean storage)
- Unique test prefix (
W0-P0-,W1-P0-, etc.) - Fresh page instance
This ensures tests don't interfere with each other.
Handling Flakiness
// Use waitFor with explicit conditions
await page.waitForFunction(() => document.querySelectorAll('task').length === 3, {
timeout: 10000,
});
// Use locator assertions (auto-retry)
await expect(page.locator('task')).toHaveCount(3);
// Avoid fixed timeouts
await page.waitForTimeout(1000); // BAD
await waitForAngularStability(page); // GOOD
Troubleshooting
Test Fails with "Element not found"
- Check if selector is correct in
constants/selectors.ts - Add wait before interaction:
await waitForAngularStability(page) - Use
await element.waitFor({ state: 'visible' }) - Check if element is in a different context (iframe, shadow DOM)
Test Timeout
- Increase timeout in specific waitFor calls
- Check if Angular is stuck - look for pending HTTP requests
- Use
page.pause()to debug interactively - Check network tab for failed requests
Flaky Tests
- Add proper waits:
waitForAngularStability,waitForAppReady - Avoid
page.waitForTimeout()- use condition-based waits - Check for race conditions - ensure state is persisted
- Use
waitForStatePersistencebefore operations that depend on saved state
Debugging
// Pause execution and open Playwright Inspector
await page.pause();
// Take screenshot
await page.screenshot({ path: 'debug.png' });
// Console log page content
console.log(await page.content());
// Get element text for debugging
const text = await page.locator('task').first().textContent();
console.log('Task text:', text);
Running Single Test
# Run specific file
npm run e2e:file tests/task-basic/task-crud.spec.ts
# Run in debug mode
npm run e2e:debug
# Run in headed mode to see browser
npm run e2e:headed
Examples
Example 1: Full Task CRUD Test
import { test, expect } from '../../fixtures/test.fixture';
test.describe('Task CRUD', () => {
test('should create, edit, and delete tasks', async ({
page,
workViewPage,
taskPage,
}) => {
await workViewPage.waitForTaskList();
// Create
await workViewPage.addTask('Task 1');
await workViewPage.addTask('Task 2');
await expect(taskPage.getAllTasks()).toHaveCount(2);
// Edit
const firstTask = taskPage.getTask(1);
await taskPage.editTaskTitle(firstTask, 'Updated Task');
await expect(taskPage.getTaskTitle(firstTask)).toContainText('Updated Task');
// Mark as done
await taskPage.markTaskAsDone(firstTask);
await expect(taskPage.getDoneTasks()).toHaveCount(1);
await expect(taskPage.getUndoneTasks()).toHaveCount(1);
});
});
Example 2: Project Workflow
test('should create project with tasks', async ({
projectPage,
workViewPage,
taskPage,
}) => {
await projectPage.createAndGoToTestProject();
await workViewPage.addTask('Project Task');
await projectPage.addNote('Important notes');
const task = taskPage.getTask(1);
await taskPage.markTaskAsDone(task);
await projectPage.archiveDoneTasks();
await expect(taskPage.getUndoneTasks()).toHaveCount(0);
});
Example 3: Settings Test
test('should configure plugin', async ({ settingsPage, page }) => {
await settingsPage.navigateToPluginSettings();
const pluginExists = await settingsPage.pluginExists('Test Plugin');
expect(pluginExists).toBeTruthy();
await settingsPage.enablePlugin('Test Plugin');
expect(await settingsPage.isPluginEnabled('Test Plugin')).toBeTruthy();
await settingsPage.navigateBackToWorkView();
await expect(page).toHaveURL(/tag\/TODAY/);
});
Getting Help
- Check existing tests in
e2e/tests/for examples - Review page objects in
e2e/pages/for available methods - Look at
constants/selectors.tsfor available selectors - Use Playwright Inspector (
npm run e2e:debug) for debugging - Check Playwright docs: https://playwright.dev/
Summary Checklist
When writing a new test:
- Create test file in appropriate
tests/subdirectory - Import
testandexpectfromfixtures/test.fixture.ts - Use page objects for all interactions
- Use selectors from
constants/selectors.ts - Add proper waits (
waitForAngularStability, etc.) - Use descriptive test names
- Ensure test is isolated (no shared state)
- Run test locally before committing
- Test passes consistently (run 3+ times)