Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a6d056a3df | ||
|
|
c3daa4a9aa | ||
|
|
c209b3c27d | ||
|
|
32c70a4657 | ||
|
|
b75ca34295 | ||
|
|
ac40774785 | ||
|
|
9a3008002d | ||
|
|
9cb2080938 | ||
|
|
08c3e47b6d |
@@ -0,0 +1,21 @@
|
||||
"""Development-track wrapper for the ownership history repair."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from importlib.util import module_from_spec, spec_from_file_location
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
_path = Path(__file__).resolve().parents[1] / "versions" / "c58a2d7e9f10_ownership_decision_history.py"
|
||||
_spec = spec_from_file_location("govoplan_ownership_decision_history_migration", _path)
|
||||
if _spec is None or _spec.loader is None:
|
||||
raise RuntimeError(f"Unable to load migration implementation from {_path}")
|
||||
_module = module_from_spec(_spec)
|
||||
_spec.loader.exec_module(_module)
|
||||
|
||||
revision = _module.revision
|
||||
down_revision = _module.down_revision
|
||||
branch_labels = _module.branch_labels
|
||||
depends_on = _module.depends_on
|
||||
upgrade = _module.upgrade
|
||||
downgrade = _module.downgrade
|
||||
@@ -0,0 +1,37 @@
|
||||
"""repair decision history on previously upgraded ownership tables
|
||||
|
||||
Revision ID: c58a2d7e9f10
|
||||
Revises: b47e6f809a13
|
||||
Create Date: 2026-09-07
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
revision = "c58a2d7e9f10"
|
||||
down_revision = "b47e6f809a13"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
# The original ownership migration gained this column after some databases
|
||||
# had already applied it. create_all/checkfirst cannot upgrade those tables.
|
||||
# Fresh installations already have it; never replace their audit evidence.
|
||||
columns = {column["name"] for column in sa.inspect(op.get_bind()).get_columns(
|
||||
"core_ownership_transfers"
|
||||
)}
|
||||
if "decisions" not in columns:
|
||||
op.add_column(
|
||||
"core_ownership_transfers",
|
||||
sa.Column("decisions", sa.JSON(), nullable=False, server_default=sa.text("'[]'")),
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
# Older installations and fresh installations at the preceding revision
|
||||
# differ. Keep the additive column and any subsequently recorded evidence.
|
||||
pass
|
||||
@@ -142,6 +142,7 @@ system:tenants:read
|
||||
system:tenants:create
|
||||
system:tenants:update
|
||||
system:tenants:suspend
|
||||
system:tenants:erase
|
||||
|
||||
system:accounts:read
|
||||
system:accounts:create
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
# Shared API client cache and authority boundaries
|
||||
|
||||
All optional WebUI modules use the Core API client. Its bounded in-memory caches
|
||||
are an optimization, never an authorization mechanism. The backend must check
|
||||
the current principal, tenant, and permissions even for conditional GETs.
|
||||
|
||||
- Identical simultaneous safe requests can share one network request. Requests
|
||||
with caller-owned cancellation are independent.
|
||||
- Responses allowing reuse have at most a 750 ms recent-response window.
|
||||
`no-store` and `Vary: *` responses are not retained. `no-cache` and zero-age
|
||||
responses require a server check; permitted ETags retain conditional GET
|
||||
support without bypassing authorization. This follows the relevant
|
||||
[HTTP cache-control semantics](https://www.rfc-editor.org/rfc/rfc9111.html#section-5.2.2).
|
||||
- Explicit `cache: "no-store"`, `"reload"`, or `"no-cache"` reads bypass older
|
||||
response data and supersede older requests for that resource. Reload is not a
|
||||
mutation. Owning read helpers must pass these options through pagination.
|
||||
- Writes invalidate caches before execution and again on settlement, including
|
||||
failures whose server outcome may be uncertain. Reads started before or
|
||||
during the write cannot seed reusable data after it finishes.
|
||||
- The shell calls `clearApiReadCache()` before explicit auth updates and when
|
||||
refreshing authoritative session data. API-settings changes, clearing the
|
||||
token, authentication expiry, and changes to the paired session/CSRF cookie
|
||||
also invalidate both stored and in-flight reuse. Cookie observation also
|
||||
covers sign-in/out in another tab; it does not read the HttpOnly session token.
|
||||
- Interactive sign-in and sign-out clear a previously saved automation key.
|
||||
Explicit key-based connection settings still select the key's identity;
|
||||
profile-only updates preserve settings identity to avoid reload loops.
|
||||
- Expired responses from superseded reads or downloads do not trigger a login
|
||||
prompt in a newer session.
|
||||
- Every completion (including 304) must still own its cache slot and generation
|
||||
before storing anything. An old caller may receive its own result, so feature
|
||||
components must continue guarding displayed state against obsolete requests.
|
||||
|
||||
Regression coverage: `npm run test:api-client-cache` uses the real client and
|
||||
isolated network fixtures. No live API or account data is involved.
|
||||
@@ -200,6 +200,19 @@ Feature providers remain responsible for their own semantics:
|
||||
Ops projects the same Core-validated receipt. It must not maintain a second
|
||||
parser with different validation or secret-handling rules.
|
||||
|
||||
Core also defines the inverse, read-only dependency-inventory contract used
|
||||
before the installer changes one of those infrastructure capabilities. An
|
||||
enabled module registers
|
||||
`infrastructure.dependency_inventory.<module_id>` and returns bounded, stable
|
||||
references to its persisted configuration or data, a lifecycle state, scope,
|
||||
numeric metrics, and a required operator action. Providers must not return
|
||||
secrets or use this read to migrate state. The Core collector validates provider
|
||||
identity and capability coverage, orders records deterministically, and marks
|
||||
the complete inventory failed when any provider raises or violates the
|
||||
contract. Ops is the authorized projection boundary; the installer remains the
|
||||
consumer and must match installation id, freshness, completion and impacted
|
||||
capability coverage before apply.
|
||||
|
||||
The admin wizard backend starts with these routes:
|
||||
|
||||
- `GET /api/v1/admin/configuration-packages/catalog`
|
||||
|
||||
@@ -1,8 +1,14 @@
|
||||
# DataGrid Sizing Contract
|
||||
|
||||
Auto-height grids reserve no empty vertical scrollbar gutter. The table fills
|
||||
its card to the right edge; an actual constrained vertical scrollbar still
|
||||
occupies its normal space. `Card bodyLayout="table"` provides an explicit
|
||||
zero-inset surface, including with loading wrappers and padded notices. The
|
||||
Organizations/IDM browser fixtures assert row geometry, not just outer shells.
|
||||
|
||||
`DataGrid` turns every declared track into a deterministic pixel layout after
|
||||
its container has a measurable width. The same contract is used on initial
|
||||
layout, container resize, persisted-layout restore, and pointer resize.
|
||||
layout, container resize, persisted-layout restore, and pointer/keyboard resize.
|
||||
|
||||
## Column Declarations
|
||||
|
||||
@@ -17,6 +23,54 @@ layout, container resize, persisted-layout restore, and pointer resize.
|
||||
- `maxWidth` bounds direct user growth and free/constrained compensation. In a
|
||||
cover layout it is a preferred maximum: passive tracks may exceed it when
|
||||
that is necessary to keep the table flush with its container.
|
||||
- `columnType: "actions"` marks a custom action/control column. Canonical
|
||||
`TableActionGroup` content is recognized automatically, even in existing
|
||||
column declarations. Use `sticky: "end"` for the normal row-action surface.
|
||||
|
||||
## Action Visibility and Constrained Containers
|
||||
|
||||
Action tracks reserve the width of actual buttons, disabled-action wrappers,
|
||||
reserved empty-state slots, gaps, and cell padding. A historic `width: 72`
|
||||
preference therefore cannot clip a four-button action group. Ordinary data text
|
||||
does not participate in this content measurement; long field values do not
|
||||
silently widen all tracks. Changes to the rendered action set are remeasured.
|
||||
|
||||
`TableActionButton` remains a compact 36 px control, including the Add action
|
||||
in an empty grid. Never stretch it with a last-column `.btn { width: 100% }`
|
||||
rule. Its shared maximum width and fixed flex basis protect against broad
|
||||
consumer button rules, which can otherwise feed stretched widths back into
|
||||
action-track measurement and consume the data area.
|
||||
|
||||
When the full group needs more than half the scroll viewport, its measured
|
||||
minimum is capped at half the viewport and the group wraps. Explicit hard
|
||||
minima remain authoritative. Custom action groups should use wrapping-capable
|
||||
flex layouts and semantic groups, preferably composing `TableActionGroup`.
|
||||
|
||||
The grid's physical width matches its pixel tracks, including horizontal
|
||||
overflow, so right-sticky actions remain inside the correct scroll bounds.
|
||||
If explicitly wide or persisted sticky tracks would obscure the readable data
|
||||
area, horizontal stickiness is released until space returns. No columns or
|
||||
actions are hidden: the labelled scroll region is focusable and supports native
|
||||
keyboard scrolling. Vertical header stickiness remains available.
|
||||
|
||||
## Resizing Controls
|
||||
|
||||
Drag a resize handle with a mouse, pen, or touch pointer. Pointer capture keeps
|
||||
the drag active when it leaves the handle. Escape or pointer cancellation
|
||||
restores the layout before that drag; releasing the pointer commits it. Losing
|
||||
window focus ends a drag without leaving the table stuck in resizing mode.
|
||||
|
||||
Each handle is a focusable vertical separator exposing its current and allowed
|
||||
widths. Left/Right changes its width by 10 px; Shift+Left/Right uses 40 px. Enter
|
||||
or a double-click resets that column's explicit override to the declared sizing
|
||||
rules. Other columns retain their preferences, so cover/compensation constraints
|
||||
still apply. These operations only change personal browser layout, never rows.
|
||||
|
||||
Deutsch: Spalten lassen sich mit Maus, Stift oder Touch ziehen. Escape verwirft
|
||||
den laufenden Ziehvorgang. Am fokussierten Trenner ändern Links/Rechts die Breite
|
||||
um 10 px, mit Umschalt um 40 px. Eingabe oder Doppelklick setzt die persönliche
|
||||
Breite dieser Spalte zurück. Schmale Aktionenspalten umbrechen ihre Schaltflächen;
|
||||
breite Tabellen bleiben horizontal scrollbar.
|
||||
|
||||
## Layout Modes
|
||||
|
||||
@@ -39,7 +93,7 @@ the column remains stopped until the pointer crosses the same boundary again.
|
||||
Only the pixel layout resulting from an explicit user resize is persisted,
|
||||
together with the container width at which the user selected it.
|
||||
Persisted widths are keyed by a signature containing column IDs, declared
|
||||
widths and bounds, resize affordances, sticky placement, initial fit, and resize
|
||||
widths and bounds, sort/filter/resize affordances, column type, sticky placement, initial fit, and resize
|
||||
behavior. A changed signature discards the old override and recomputes the
|
||||
declared layout.
|
||||
|
||||
@@ -51,7 +105,10 @@ contracts, persisted tracks may shrink toward their hard minima. The layout
|
||||
retains only the amount of horizontal overflow deliberately created by the
|
||||
user; an exact-cover layout therefore remains exact-cover at narrower widths.
|
||||
Legacy snapshots from the former hard-pixel persistence contract are discarded
|
||||
once and recomputed from the declared column layout.
|
||||
once and recomputed from the declared column layout. The current `v3` signature
|
||||
also discards old snapshots that predate action and header-control minima;
|
||||
sort/filter preferences remain intact. Measured action widths are not included
|
||||
in the signature, so changing rows does not erase user sizing intent.
|
||||
|
||||
## Regression Matrix
|
||||
|
||||
@@ -72,3 +129,11 @@ once and recomputed from the declared column layout.
|
||||
|
||||
`webui/tests/data-grid-actions.test.tsx` also verifies the rendered fixed-cover
|
||||
shape and guards against reintroducing a synthetic buffer cell.
|
||||
|
||||
`webui/conformance/tests/data-grid-layout.spec.ts` exercises the real rendered
|
||||
grid with deliberately undersized action preferences, constrained containers,
|
||||
horizontal scrolling, changing/empty action sets, keyboard and pointer resizing,
|
||||
Escape cancellation, remount persistence, responsive contraction and restoration,
|
||||
free/content mode, and constrained compensation. Run with
|
||||
`npm run test:conformance -- data-grid-layout.spec.ts`; its isolated test server
|
||||
is stopped automatically afterwards.
|
||||
|
||||
@@ -74,6 +74,19 @@ Operator rule: take a database backup before applying migrations or destructive
|
||||
module retirement. For non-SQLite databases, configure deployment-specific
|
||||
backup/restore hooks for the module installer.
|
||||
|
||||
#### Ownership-history upgrade repair
|
||||
|
||||
Core revision `c58a2d7e9f10` repairs existing ownership-transfer tables that
|
||||
predate the `decisions` column. Such installations can otherwise return HTTP
|
||||
500 from `/api/v1/ownership/transfers`, including Campaign Settings. Apply the
|
||||
normal forward migrations after taking a backup; do not stamp a revision or
|
||||
recreate the table. The repair is available on both migration tracks, adds only
|
||||
the missing non-null JSON column, and initializes old rows with an empty list.
|
||||
It preserves owners, approvals, transfer states, revisions, timestamps, and any
|
||||
existing decision history. Historical decisions are not reconstructed or
|
||||
invented. Downgrading this repair retains the additive column and its evidence.
|
||||
Verify that ownership-transfer listing and Campaign Settings load after upgrade.
|
||||
|
||||
### PostgreSQL Production Target
|
||||
|
||||
PostgreSQL is the primary development and production target. SQLite remains
|
||||
@@ -408,6 +421,27 @@ To stop PostgreSQL and Redis when the launcher exits:
|
||||
GOVOPLAN_STOP_PROFILE_DEPENDENCIES_ON_EXIT=1 tools/launch/launch-production-like-dev.sh
|
||||
```
|
||||
|
||||
## Development WebUI Dependency Caches
|
||||
|
||||
The application and browser-conformance harness share installed JavaScript
|
||||
packages but must not share Vite's optimized-dependency cache. The application
|
||||
uses `webui/node_modules/.vite/govoplan-app`; the conformance harness uses
|
||||
`webui/node_modules/.vite/govoplan-conformance`. Keep these explicit sibling
|
||||
directories when adding development or test configurations. Setting a different
|
||||
Vite `root` alone does not isolate this cache.
|
||||
|
||||
A shared cache can make otherwise healthy Workflow, Dataflow or deferred editors
|
||||
show “The resource could not be loaded.” The browser then reports an asset such
|
||||
as `@xyflow_react.js` with HTTP 504 `Outdated Optimize Dep`, while the corresponding
|
||||
API still returns HTTP 200. This is not a missing workflow permission or a reason
|
||||
to rerun a pipeline. Preserve unsaved work, let the existing development server
|
||||
reload the corrected configuration (or restart that WebUI server), then reload
|
||||
the browser. Do not clear application data, change grants or restart delivery
|
||||
workers to repair a frontend dependency cache.
|
||||
|
||||
Run `npm run test:vite-cache-isolation` in `govoplan-core/webui` to verify the real
|
||||
resolved Vite configurations without starting servers or overwriting caches.
|
||||
|
||||
## Module Install/Uninstall Operations
|
||||
|
||||
Use Admin > System > Modules for planning. The running API server validates and
|
||||
|
||||
@@ -17,7 +17,7 @@ domain modules own their compositions.
|
||||
| Full-canvas workspace | Navigation/content and list/detail canvases that own pane geometry and scrolling | Navigation and split variants, primary-pane width, pane-owned or contained scrolling, responsive stacking or navigation collapse, pane labels, and contextual-help identity are centralized without encoding domain navigation | `WorkspaceLayout.tsx`, `workspace-layout.test.tsx`, Core Settings, Access administration, Docs, Organizations, Campaign, Templates, Approvals, and `check-shared-webui-layouts.py`; the raw-workspace exception baseline is empty |
|
||||
| Full-height module frame | Outer module landmark and viewport/container sizing | `WorkspaceFrame` centralizes surface, overflow, box sizing, accessible naming, help identity, and application-viewport height so modules do not copy the `100vh - shell` frame | `WorkspaceFrame.tsx`, `layout-primitives.test.tsx`, Dataflow, Workflow, Datasources, Distribution Lists, Notifications, Tasks, Scheduling, Forms, Portal, Projects, Records, and Reporting |
|
||||
| Responsive action toolbar | Domain-neutral action and filter grouping for pages, workspaces, editors, and overlays | Density, surface, grouping, flexible space, accessible naming, toolbar help identity, and responsive wrapping are centralized while modules retain action wording, authority, and consequence | `ActionToolbar.tsx`, `layout-primitives.test.tsx`, WYSIWYG, Calendar, Files, Forms, Templates, and the product-wide primitive check |
|
||||
| Semantic page and pane action bars | Overview, collection, detail, editor, and workspace intent declared independently from frame geometry; full-canvas panes add workspace/collection/detail/editor scope | Core renders leading Reload from a guarded descriptor; editor persistence owns clean, dirty, invalid, saving, failed, and conflict feedback plus guarded Discard and far-right Save; destructive actions occupy an explicit named boundary; read-only surfaces do not invent Save | `PageActionBar.tsx`, `WorkspaceActionBar.tsx`, `PAGE_LAYOUT_USAGE_GUIDELINES.md`, component and browser conformance, every headed page and full-canvas workspace, and the discovery-based `check-shared-webui-layouts.py` |
|
||||
| Semantic page and pane action bars | Overview, collection, detail, editor, and workspace intent declared independently from frame geometry; full-canvas panes add workspace/collection/detail/editor scope | Core renders guarded Reload in the right-aligned group immediately before Create/primary actions; editor persistence owns clean, dirty, invalid, saving, failed, and conflict feedback plus guarded Discard and far-right Save; destructive actions occupy an explicit named boundary; read-only surfaces do not invent Save | `PageActionBar.tsx`, `WorkspaceActionBar.tsx`, `PAGE_LAYOUT_USAGE_GUIDELINES.md`, component and browser conformance, every headed page and full-canvas workspace, and the discovery-based `check-shared-webui-layouts.py` |
|
||||
| Catalogue and state composition | Search/filter bars, selectable navigation lists, count badges, and empty/blocked/error panels | Width, surface, wrap, selection geometry, title/description truncation, numeric emphasis, state sizing, tone and action placement are centralized; modules retain query behavior, object state and consequences | `FilterBar.tsx`, `SelectionList.tsx`, `CountBadge.tsx`, `StatePanel.tsx`, `layout-primitives.test.tsx`, and list/detail modules across Cases, Committee, Dataflow, Forms, Notifications, Portal, Postbox, Projects, Records, Reporting, Tasks, Templates, and Workflow |
|
||||
| Content and form grids | Equal-column content, field, and native-form geometry | Explicit 1–4 columns, standard gaps, item spans, alignment, and named narrow/workspace/standard/wide collapse points replace generic and module-prefixed copies; unequal domain tracks remain local | `ContentGrid.tsx`, `layout-primitives.test.tsx`, Core dashboard/settings/mail, Calendar dialogs, Forms editor, Datasources, Postbox, Campaign, administration surfaces, and the product-wide primitive check |
|
||||
| Content sections | Repeated editor/detail section surfaces | Border, surface, compact/default density, stacked flow and block rhythm are centralized without encoding section contents | `ContentSection.tsx`, `layout-primitives.test.tsx`, Datasources, Distribution Lists, Templates, Dataflow, and Workflow |
|
||||
@@ -27,9 +27,46 @@ domain modules own their compositions.
|
||||
| Dialog anatomy | Shared outer dialog plus composable body and footer regions | Size and administration variants, body padding, descriptions, notices, fixed action wrapping, native form flow, and section grouping are centralized; focus trapping and stack lifecycle remain unchanged | `Dialog.tsx`, `DialogAnatomy.tsx`, `dialog-focus.test.tsx`, `layout-primitives.test.tsx`, Addresses, Calendar, Records, Datasources, Distribution Lists, Files, and Templates |
|
||||
| Definition-editor visuals | Reusable graph palette, canvas chrome, node icon/port geometry, empty overlay and floating activity state | Core owns visual and responsive anatomy while node/edge types, validation, execution, provenance and workflow semantics remain in Dataflow or Workflow | `DefinitionPalette.tsx`, `DefinitionNodeIcon.tsx`, `FloatingStatus.tsx`, shared definition styles, Dataflow and Workflow structure/build checks |
|
||||
| Shared configuration primitives | Cross-module component contract | Dialog focus, blocker structure, disabled-action focus, route/page/field/action F1 help, unsaved changes, confirmation, loading, alerts, problem lists, and policy provenance are centralized | Core component tests, `CONTEXTUAL_HELP_CONTRACT.md`, and module-permutation build |
|
||||
| Measured operation feedback | `LoadingFrame` over existing content | Use `indicator="none"` with native measured progress for long-running operations; `progress={null}` means unknown, never a synthetic percentage. Keep dialog content inert and close controls disabled until success or error. Existing consumers retain their loading indicator. | Files archive inspection/extraction, layout primitive tests, managed-archive browser conformance |
|
||||
|
||||
## Boundary
|
||||
|
||||
Side-rail customization uses the shared `NavigationPreferenceEditor` for system,
|
||||
tenant, personal, and View layouts. Modules and labelled separators share one
|
||||
ordered list, with pointer drag-and-drop, keyboard reordering, and explicit
|
||||
add/remove actions. Consumers retain persistence and dirty-state ownership;
|
||||
mounting the editor does not create a draft change. See
|
||||
`NAVIGATION_LAYOUT_CONTRACT.md` for inheritance, locked items, optional-module
|
||||
preservation, and collapsed-rail grouping.
|
||||
|
||||
Action columns use `TableActionGroup` or declare `columnType: "actions"` when
|
||||
their composition differs. DataGrid owns measured action minima, initial column
|
||||
allocation, persistent resizing, and local horizontal scrolling; consumers must
|
||||
not compensate with clipped overflow or copied fixed widths. See
|
||||
`DATAGRID_SIZING_CONTRACT.md`. Dialog forms use `DialogForm` and `FormGrid` inside
|
||||
the shared size-bounded dialog. Do not add a content minimum wider than the
|
||||
panel's padded interior. Genuinely wide content, such as a table, owns its own
|
||||
local scroller instead of making the entire dialog scroll horizontally.
|
||||
|
||||
In `FormGrid` and `FormLayout`, direct `FormField` and `ToggleSwitch` items
|
||||
align their controls at the row's lower edge. A single control inside a
|
||||
`GridItem` follows the same rule. Labels may wrap without shifting adjacent
|
||||
switches up into the label row. Do not add per-module top margins or empty
|
||||
labels; single-column layouts must not retain a phantom label spacer.
|
||||
|
||||
Credential editors resolve public reference labels when opened. A failed
|
||||
save displays its error inside the dialog and keeps the entered draft for an
|
||||
explicit retry. While a write is pending, repeated submission, edits, and
|
||||
dialog dismissal are disabled; no configured secret is read back from storage.
|
||||
|
||||
The shared rich-text editor emits content changes only for actual document
|
||||
edits. Mounting, read-only changes, loading a saved value, and switching between
|
||||
visual and source inspection must preserve the controlled HTML without marking
|
||||
the owning page dirty. This is especially important for legacy Campaign HTML:
|
||||
merely visiting Template must not normalize it or require a save on leaving.
|
||||
The WYSIWYG lifecycle browser conformance covers both visual and legacy-source
|
||||
initial content, as well as genuine typing.
|
||||
|
||||
Files and Mail are the first two external consumers of the layered
|
||||
server/credential/policy pattern. Their own repositories retain provider
|
||||
discovery, transport behavior, authorization, and migration evidence. Remaining
|
||||
|
||||
@@ -103,6 +103,14 @@ modal at narrow widths, closes with Escape, and restores focus to the triggering
|
||||
control. Module journeys should add their own exact high-risk mappings; they do
|
||||
not need to reimplement the keyboard or dialog mechanics.
|
||||
|
||||
The same conformance suite mounts the production Forms Runtime self-service and
|
||||
assisted Anwohnerparkausweis surfaces with German module translations. Desktop
|
||||
and mobile runs traverse native controls by keyboard, inspect accessible names
|
||||
and landmarks, run WCAG 2.1 A/AA automation, verify responsive overflow, and
|
||||
retain independent per-field assisted provenance. Physical assistive-technology
|
||||
spot checks remain release evidence rather than being represented as browser
|
||||
automation.
|
||||
|
||||
## Verification
|
||||
|
||||
```bash
|
||||
|
||||
@@ -128,6 +128,8 @@ The following contracts are the baseline API that modules can rely on:
|
||||
- bounded reference-option search provider contract
|
||||
- single-tenant and optional batched tenant summary provider contracts
|
||||
- tenant delete-veto provider contract
|
||||
- provider-neutral tenant-erasure preview, step, idempotency, and
|
||||
reconciliation contracts in `govoplan_core.core.tenant_erasure`
|
||||
- WebUI module contribution contract
|
||||
- navigation metadata contract
|
||||
- command/event envelope contract
|
||||
@@ -149,6 +151,17 @@ Destructive tenant lifecycle planning deliberately continues to use the
|
||||
single-tenant path so it invokes every registered provider for the target
|
||||
tenant, independent of ordinary list-page projections.
|
||||
|
||||
Governed populated-tenant erasure is separate from ordinary delete vetoes.
|
||||
Modules contribute `tenancy.erasure_provider.<module_id>` capabilities with a
|
||||
bounded resource inventory, explicit erase/retain/legal-hold/external/key/
|
||||
backup dispositions, ordered destructive warnings, idempotent step execution,
|
||||
and reconciliation. The collector fails closed when a provider is invalid or
|
||||
fails. A module with nonzero tenant summary counts and no erasure capability is
|
||||
reported as unsupported and blocks execution; modules with neither contract
|
||||
are explicitly projected as outside tenant-persistence scope. Provider
|
||||
evidence contains counts and stable references only and must never contain
|
||||
secrets or erased subject data.
|
||||
|
||||
This list is the Milestone A kernel-contract freeze baseline. New module work
|
||||
may extend the kernel by adding explicit contracts, but existing contracts must
|
||||
remain source-compatible through the 0.1.x split line unless a migration shim
|
||||
@@ -1049,6 +1062,23 @@ available owner route. It emits `govoplan:product-surface-route-resolved` before
|
||||
the redirect so migration telemetry can observe alias use without making the
|
||||
technical module part of the ordinary label.
|
||||
|
||||
The shell projects every authorized, View-visible owner route with a product
|
||||
contribution into one stable product navigation item. The product label and
|
||||
entry path replace package topology in the primary rail; every contributing
|
||||
owner path still marks that item active. `All available tools` is a collapsed,
|
||||
permission-derived catalogue built independently of the active View, so a
|
||||
focused workflow cannot remove the explicit escape. It may reveal an
|
||||
authorized owner route that a View omitted, but never an unauthorized route.
|
||||
Navigation visibility preferences do not delete catalogue entries, and the
|
||||
original owner routes remain compatible deep links.
|
||||
|
||||
The initial promoted destinations are `work.items` at `/work`,
|
||||
`meetings.calendar` at `/agenda`, `communication.messages` at `/messages`
|
||||
(with `/inbox` as an alias), and `records.files` at `/documents`. Their labels
|
||||
and availability language are centralized in Core while Tasks, Calendar,
|
||||
Mail/Postbox, and Files retain route, command, search, help, documentation,
|
||||
authorization, and data ownership.
|
||||
|
||||
Use `ProductAvailabilityState` for unavailable and degraded outcomes. The
|
||||
ordinary state explains the attempted outcome, consequence, recovery path and
|
||||
responsible role. Exact module, capability, provider and correlation values may
|
||||
@@ -1605,6 +1635,16 @@ Unsigned/unhashed remote bundles are skipped. This keeps remote loading a
|
||||
controlled deployment option rather than a replacement for release package
|
||||
builds.
|
||||
|
||||
A failed local WebUI package import receives one automatic retry after 250 ms.
|
||||
Descriptor validation still fails closed; it is not bypassed by the retry.
|
||||
If an enabled local module still cannot load, the signed-in shell warns that its
|
||||
screens and integrations may be unavailable and identifies the module. This is
|
||||
a loading failure, not an uninstall. Save other drafts before manually reloading
|
||||
the page; there is no automatic page reload or persistent retry loop. A verified
|
||||
remote fallback that successfully loads clears that module's warning. Packages
|
||||
absent from the optional build graph remain absent, and effective View filtering
|
||||
continues to control which loaded UI capabilities are exposed.
|
||||
|
||||
## Maintenance Mode
|
||||
|
||||
Maintenance mode is the required operating state for package install/uninstall
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
# Shared navigation layout contract
|
||||
|
||||
Core owns `NavigationPreferenceEditor`, ordered layout resolution, and rail
|
||||
rendering. Admin, Tenancy, personal Settings and Views reuse this editor. They
|
||||
own loading, authorization, Save, Reload and dirty-state guards; the editor
|
||||
emits a draft only after a real edit. A drag onto the same position, keyboard
|
||||
pickup/drop without movement, and opening inherited settings do not save or
|
||||
create an override.
|
||||
|
||||
## Stored document and precedence
|
||||
|
||||
The version-1 navigation document retains `order`, `hidden` and `locked` and
|
||||
adds optional `separators`, each containing a stable `separator:`-prefixed ID
|
||||
and an optional plain-text label of at most 120 characters. Separator IDs and
|
||||
module navigation IDs occupy the same `order` list. Separators are presentation
|
||||
metadata and never become routes, modules, permissions or authorized surfaces.
|
||||
|
||||
Omitting `separators` or using null preserves inherited grouping. An explicit
|
||||
empty array removes grouping. Resetting the entire navigation document to null
|
||||
removes that scope's override. Existing order-only documents remain readable;
|
||||
the editor materializes group markers into a draft only when edited. Unknown
|
||||
optional-module order IDs remain stored when currently visible items move, so
|
||||
uninstalling or temporarily disabling a module does not destroy its preference.
|
||||
|
||||
User order and visibility take precedence over tenant and system preferences.
|
||||
System and tenant visibility locks accumulate; lower scopes cannot hide those
|
||||
destinations, but may move them. Views may supply a navigation presentation
|
||||
inside the already authorized and View-filtered destination set. An explicit
|
||||
personal order/layout or visibility preference takes precedence over that
|
||||
presentation, not over authorization or the View's surface restrictions.
|
||||
Views cannot introduce locks. When multiple modules contribute one product
|
||||
entry, it inherits the earliest effective rail position/section and all
|
||||
authorized contributors' locks; this does not change operational route
|
||||
selection. An owner alias in View layout refers to that composed entry.
|
||||
|
||||
## Interaction and reuse
|
||||
|
||||
Drag the handle to move either a module or separator before/after another row.
|
||||
The handle also supports Space to pick up, arrow keys to move, Enter to drop,
|
||||
and Escape to restore the pre-drag draft. Up/down buttons offer the same moves.
|
||||
Add module restores an available hidden entry; Remove only hides navigation,
|
||||
never uninstalls a module or deletes records. Add separator inserts a new
|
||||
optional group label. Remove separator changes grouping only.
|
||||
|
||||
Expanded rails display group labels without divider lines. Collapsed rails
|
||||
replace these labels with horizontal group dividers; empty groups are not
|
||||
rendered. Both modes use the same resolved order.
|
||||
The editor receives product-area metadata to show inherited grouping and uses
|
||||
container-responsive rows rather than a fixed dialog/page width. Its English
|
||||
and German labels load with the editor, not the initial shell bundle.
|
||||
Give the ordered list a full-span `GridItem` when a settings page contains
|
||||
multiple cards; do not squeeze the entire editor into an otherwise half-empty
|
||||
two-column settings grid. Central spacing tokens provide real row padding and
|
||||
separation at both wide and narrow sizes, covered by computed-style assertions.
|
||||
|
||||
## Verification
|
||||
|
||||
Core navigation unit tests and HTTP settings/profile tests cover persistence,
|
||||
separator inheritance, explicit flat layouts, reset and locks. Module-capability
|
||||
tests cover View aliases, composed destinations and personal precedence. The
|
||||
browser conformance suite tests all four editor scopes, pointer/keyboard moves,
|
||||
no-op cleanliness, unavailable-module preservation, collapsed dividers and a
|
||||
German narrow read-only layout. Use the same shared component for future
|
||||
navigation-definition surfaces rather than implementing another sortable list.
|
||||
@@ -69,11 +69,61 @@ page or pane action bar.
|
||||
|
||||
| Page kind | Leading group | Trailing group |
|
||||
| --- | --- | --- |
|
||||
| Overview | Reload when refreshable, then context | Help, then ordinary primary actions |
|
||||
| Collection | Reload when refreshable, then collection context such as export | Help, then Create at the far right |
|
||||
| Detail | Reload when refreshable, then object context | Help, ordinary primary actions, then a separated destructive group |
|
||||
| Editor | Reload only when refresh is a distinct safe operation, then context | Dirty state, Help, ordinary primary actions, separated destructive actions, Discard, then Save at the far right |
|
||||
| Workspace | Reload when the coordinated projection can become stale, then task context | Help, ordinary primary actions, then a separated destructive group |
|
||||
| Overview | Context | Help, Reload when refreshable, then ordinary primary actions |
|
||||
| Collection | Collection context such as export | Help, Reload when refreshable, then Create at the far right |
|
||||
| Detail | Object context | Help, Reload when refreshable, ordinary primary actions, then a separated destructive group |
|
||||
| Editor | Context | Dirty state, Help, Reload if distinctly safe, ordinary primary actions, separated destructive actions, Discard, then Save at the far right |
|
||||
| Workspace | Task context | Help, Reload when refreshable, ordinary primary actions, then a separated destructive group |
|
||||
|
||||
Reload and Create belong to the same right-aligned group, in that order. A
|
||||
collection-wide toolbar stays above its workspace, not inside the left tree or
|
||||
conditionally inside an editor. Changing selection or opening an editor must
|
||||
not remove it. Permission-blocked creation remains visible with an explanation.
|
||||
On narrow screens the trailing group wraps while retaining right alignment and
|
||||
the same DOM/keyboard order.
|
||||
|
||||
Use `Card bodyLayout="table"` for table surfaces, including tables wrapped by
|
||||
`LoadingFrame`. This removes body padding explicitly, without relying on the
|
||||
number of children or negative margins. Place any meaningful explanation or
|
||||
warning in a padded `ContentSection`; do not add a redundant tagline to every
|
||||
table. Use `ContentGrid` for sibling cards so spacing does not depend on fragments.
|
||||
|
||||
Use `MultiSelectFilter` for standalone list facets. It and DataGrid share the
|
||||
same checkbox body and Select all / Deselect all behavior. `null` means no
|
||||
restriction, `[]` means no matches, and multiple values mean OR within a facet.
|
||||
Apply remote filters before server pagination/limits and discard stale reads.
|
||||
Do not replace this with rows of toggles or implement a second checkbox menu.
|
||||
The dropdown's body portal escapes clipped containers. Inside Core dialogs it
|
||||
joins the existing dialog stack: Tab/Shift+Tab stay in the filter, Space toggles
|
||||
the focused checkbox, and Escape closes only the filter and restores its
|
||||
trigger. Long option labels wrap without widening the popup.
|
||||
|
||||
Keep facet definitions, URL serialization and request cancellation in one owning
|
||||
module adapter when the same search appears on a page and in an overlay. Do not
|
||||
translate an explicit empty selection into an unrestricted backend query. Keep
|
||||
legacy API meanings at the adapter boundary; retain scope and unrelated URL
|
||||
parameters when clearing filters. A query, context, account or tenant change
|
||||
invalidates both initial and cursor requests, including results still visible
|
||||
during a debounce interval.
|
||||
|
||||
Explorer workspaces keep collection commands in a persistent header. Files
|
||||
uses Reload, Create folder, then primary Upload; frequent selected-item actions
|
||||
stay near the list. Group less-common selection and connection operations in
|
||||
labelled domain dialogs using `Dialog`, `FormSection` and shared action bars,
|
||||
with an explicit destructive section. Do not move an overloaded toolbar into
|
||||
another ungrouped row. Mail's read-only workspace has one Reload for its current
|
||||
profile, folder, index and preview; narrower refreshes belong in Mailbox tools.
|
||||
Do not invent a New or Save button on a workspace that owns neither workflow.
|
||||
Reload must not become import, synchronization, delivery or another mutation.
|
||||
Explicit Reload reads must bypass short-lived client response reuse (for
|
||||
example, pass `cache: "no-store"` through the owning read API), including each
|
||||
page of a refreshed listing. Routine navigation may retain normal deduplication.
|
||||
Conformance must observe a fresh request, not just an enabled Reload button.
|
||||
|
||||
Tree icons/disclosure controls expand and collapse; labels select. A module's
|
||||
`ExplorerTree.onOpen` must not toggle expansion. Use occurrence-specific node
|
||||
IDs when the same semantic record appears in multiple branches; selection and
|
||||
ancestor expansion must follow the clicked occurrence, not every copy.
|
||||
|
||||
Reload means re-fetch or re-evaluate the current surface. A page declaring
|
||||
`refreshable` must provide it, and a non-refreshable page must not use Reload as
|
||||
|
||||
@@ -95,6 +95,15 @@ package entry. Keep one committed full-product release lockfile at
|
||||
`webui/package.release.json` in a clean release workspace. Development
|
||||
`package-lock.json` may continue to point at local `file:` dependencies.
|
||||
|
||||
The default WebUI build discovers every declared, installed module package,
|
||||
including Tasks and its Work page, dashboard widget, and Quick Access tool.
|
||||
Discovery still respects enabled backend modules and user permissions; adding
|
||||
a package never grants access. `GOVOPLAN_WEBUI_MODULE_PACKAGES` selects an
|
||||
explicit smaller build when set, and an explicitly empty value selects
|
||||
core-only. The prebuild interface check compares the default descriptor list
|
||||
with the package manifest so explicit permutation tests cannot conceal a
|
||||
module accidentally omitted from the ordinary build.
|
||||
|
||||
Frontend module permutations are regression-tested through
|
||||
`GOVOPLAN_WEBUI_MODULE_PACKAGES` and temporary build output, not through
|
||||
committed lockfiles for every possible combination. If a smaller composition
|
||||
|
||||
@@ -54,3 +54,9 @@ but their surrounding controls must still use the shared tokens.
|
||||
custom-override validation/application, and representative
|
||||
Campaign, Calendar, Files, and Mail token consumption. The check runs before a
|
||||
production WebUI build.
|
||||
|
||||
Runtime validation and token application live in the dependency-free
|
||||
`webui/src/components/appearanceOverrides.ts`; both the shell and the shared
|
||||
editor use it. The shell must not import the editor to apply an existing theme:
|
||||
settings controls load with their route, while valid saved colors apply
|
||||
synchronously and invalid documents still fail closed before any token is set.
|
||||
|
||||
@@ -57,7 +57,7 @@ contestability, responsibility, and traceability at the point of action.
|
||||
| UX-031 | Public controls and extension contributions use stable, module-namespaced interface identities. Shared controls expose `interfaceId` and `helpTopicId`; generated source anchors are inventory evidence, not a substitute for an explicit ID when documentation, policy, or automation refers to the control. | Accepted | Core and module WebUIs |
|
||||
| UX-032 | `F1` resolves help from the focused field or action, then its dialog/section/page and registered route. Focused contexts retain the page fallback; Docs applies audience and permission filtering and falls back to visible module documentation. | Accepted | Core shell, Docs, and all module WebUIs |
|
||||
| UX-033 | Global search is the left-most titlebar command, immediately before language selection. Its icon, `F3`, and `Ctrl`/`Cmd`+`K` all open the same permission-aware search overlay; the titlebar does not reserve a persistent query field. | Accepted | Core shell and Search WebUI |
|
||||
| UX-034 | Every headed `PageLayout` declares one of `overview`, `collection`, `detail`, `editor`, or `workspace` independently from its standalone/workspace/embedded geometry. Its actions use the matching semantic `PageActionBar`: a refreshable page must provide Reload in the leading slot; collections keep Create far right; read-only pages do not invent Save. | Accepted | Core and all module WebUIs |
|
||||
| UX-034 | Every headed `PageLayout` declares one of `overview`, `collection`, `detail`, `editor`, or `workspace` independently from its standalone/workspace/embedded geometry. Its actions use the matching semantic `PageActionBar`: refreshable pages provide Reload in the right-aligned trailing group immediately before Create/primary actions; collections keep Create far right; read-only pages do not invent Save. The trailing placement supersedes the earlier leading-Reload rule (2026-09-07, Core #295). | Accepted | Core and all module WebUIs |
|
||||
| UX-035 | Editor action bars expose clean, dirty, and saving state; always retain Discard immediately before the far-right Save; centrally disable both while clean or saving; and participate in the unsaved-change navigation guard. Danger actions occupy the explicit separated destructive group after ordinary actions and before editor persistence. | Accepted | Core and all module WebUIs |
|
||||
|
||||
## Confirmed Implementation Decisions
|
||||
@@ -236,7 +236,9 @@ instead of reproducing their behavior.
|
||||
not self-explanatory.
|
||||
- `help` content is contextual guidance, not the accessible name. The persisted
|
||||
`show_inline_help_hints` user preference hides only the `InlineHelp` marker by
|
||||
applying `ui-hide-help-hints` at the document root.
|
||||
applying `ui-hide-help-hints` at the document root. When shown, the shared
|
||||
marker is a labelled, keyboard-focusable help control and exposes its tooltip
|
||||
on focus as well as pointer hover.
|
||||
- Shared action-bearing components accept an optional disabled reason. In
|
||||
particular, `MailServerSettingsPanel` forwards protocol-specific test
|
||||
blockers into the shared focusable disabled-action tooltip; modules provide
|
||||
|
||||
@@ -59,6 +59,14 @@ The first budgeted full-product build reported:
|
||||
|
||||
## Verification
|
||||
|
||||
Development startup explicitly prebundles the Excel reader's browser/universal
|
||||
entrypoints and the lazy rich-text editor's Tiptap dependencies. These are
|
||||
Core-installed vendor dependencies, not eager optional-module imports. This
|
||||
avoids first-time Campaign/Template navigation triggering a second dependency
|
||||
optimization and page reload. Module descriptors and pages remain lazy, and
|
||||
production bundle budgets remain unchanged. The Core interface-pattern check
|
||||
verifies this include list and keeps optional GovOPlaN modules excluded.
|
||||
|
||||
```bash
|
||||
cd /mnt/DATA/git/govoplan-core/webui
|
||||
npm run build
|
||||
@@ -69,3 +77,33 @@ npm run test:module-permutations
|
||||
The build gate also catches accidental eager imports: a page pulled into the
|
||||
entry closure consumes the initial budget, while an oversized page or module
|
||||
descriptor consumes the asynchronous chunk budget.
|
||||
|
||||
The startup shell imports appearance validation/application from the pure
|
||||
`appearanceOverrides.ts` runtime. Settings-only color controls, JSON import/export,
|
||||
and previews remain in `AppearanceOverridesEditor.tsx` behind the existing lazy
|
||||
Settings route. Importing a runtime helper from a module that also owns editor
|
||||
components can accidentally pull the entire editor into the startup chunk.
|
||||
Public helper exports remain compatible; theme application is still synchronous.
|
||||
The versioned default color document and its deep-clone helper live in
|
||||
`appearanceOverrideDefaults.ts`, loaded with that editor. Applying saved overrides
|
||||
does not load editor defaults or construct a draft. The default values and public
|
||||
helper names are unchanged; the theme regression checks independent draft clones
|
||||
as well as synchronous validation, application, and reset.
|
||||
|
||||
`PasswordField` keeps ordinary input and reveal controls synchronous. Its
|
||||
optional `PasswordGeneratorDialog` is imported only after an enabled, editable
|
||||
generator is explicitly opened, not for every sign-in/password field. Loading
|
||||
and failures use the shared resource boundary; the underlying field remains
|
||||
usable. Closing or revoking generation while loading cannot apply a candidate.
|
||||
The secure browser RNG, generation policy, public exports, and explicit
|
||||
"Use password" confirmation remain unchanged. The isolated browser fixture
|
||||
does not import the Core barrel, so it can verify that the generator is not
|
||||
requested before opening it, along with cancel/use and focus restoration.
|
||||
|
||||
Deutsch: Normale Passworteingabe und Sichtbarkeitssteuerung bleiben unmittelbar
|
||||
verfügbar. Der optionale Generator wird erst beim bewussten Öffnen eines
|
||||
aktivierten, bearbeitbaren Felds geladen; Lade- und Fehlerzustände nutzen die
|
||||
gemeinsame Ressourcenanzeige. Ohne "Passwort verwenden" wird kein Kandidat
|
||||
übernommen. Sichere Browser-Zufallszahlen, Richtlinien und öffentliche
|
||||
Schnittstellen bleiben unverändert. Wird die Generierung während des Ladens
|
||||
deaktiviert, öffnet eine verspätete Antwort keinen Dialog.
|
||||
|
||||
@@ -2271,6 +2271,503 @@
|
||||
"release": "0.1.18",
|
||||
"squash_policy": "reviewed-manual",
|
||||
"track": "release"
|
||||
},
|
||||
{
|
||||
"heads": [
|
||||
{
|
||||
"owner": "govoplan-notifications",
|
||||
"revision": "6e2f91ab4c70"
|
||||
},
|
||||
{
|
||||
"owner": "govoplan-poll",
|
||||
"revision": "6e7f8a9b0c1d"
|
||||
},
|
||||
{
|
||||
"owner": "govoplan-dashboard",
|
||||
"revision": "7b9d2f4a6c8e"
|
||||
},
|
||||
{
|
||||
"owner": "govoplan-tasks",
|
||||
"revision": "7c4d9a2e1f30"
|
||||
},
|
||||
{
|
||||
"owner": "govoplan-records",
|
||||
"revision": "8a6c4e2f1b3d"
|
||||
},
|
||||
{
|
||||
"owner": "govoplan-voting",
|
||||
"revision": "8b9c0d1e2f3a"
|
||||
},
|
||||
{
|
||||
"owner": "govoplan-tickets",
|
||||
"revision": "8d1f4b7a2c5e"
|
||||
},
|
||||
{
|
||||
"owner": "govoplan-workflow-engine",
|
||||
"revision": "8d5a2f7c1b4e"
|
||||
},
|
||||
{
|
||||
"owner": "govoplan-quick-access",
|
||||
"revision": "9a4e6c2d8f10"
|
||||
},
|
||||
{
|
||||
"owner": "govoplan-helpdesk",
|
||||
"revision": "9e2a5c8f1b4d"
|
||||
},
|
||||
{
|
||||
"owner": "govoplan-files",
|
||||
"revision": "a2b3c4d5e6f8"
|
||||
},
|
||||
{
|
||||
"owner": "govoplan-files",
|
||||
"revision": "a2b3c4d5e6f9"
|
||||
},
|
||||
{
|
||||
"owner": "govoplan-dataflow",
|
||||
"revision": "a3d7f1c5e9b2"
|
||||
},
|
||||
{
|
||||
"owner": "govoplan-templates",
|
||||
"revision": "a3f7c9d2e1b4"
|
||||
},
|
||||
{
|
||||
"owner": "govoplan-mail",
|
||||
"revision": "a4c5d6e7f809"
|
||||
},
|
||||
{
|
||||
"owner": "govoplan-organizations",
|
||||
"revision": "a61e4d9c72b8"
|
||||
},
|
||||
{
|
||||
"owner": "govoplan-wiki",
|
||||
"revision": "a7c2e9f4b1d6"
|
||||
},
|
||||
{
|
||||
"owner": "govoplan-cases",
|
||||
"revision": "a7c4e2f9b1d6"
|
||||
},
|
||||
{
|
||||
"owner": "govoplan-mandates",
|
||||
"revision": "a8b1c2d3e4f5"
|
||||
},
|
||||
{
|
||||
"owner": "govoplan-approvals",
|
||||
"revision": "a91c4e72b5d8"
|
||||
},
|
||||
{
|
||||
"owner": "govoplan-policy",
|
||||
"revision": "a9c4e7b2d5f8"
|
||||
},
|
||||
{
|
||||
"owner": "govoplan-search",
|
||||
"revision": "b2c3d4e5f607"
|
||||
},
|
||||
{
|
||||
"owner": "govoplan-tenancy",
|
||||
"revision": "b3d8e1f4a6c2"
|
||||
},
|
||||
{
|
||||
"owner": "govoplan-risk-compliance",
|
||||
"revision": "b9c0d1e2f3a4"
|
||||
},
|
||||
{
|
||||
"owner": "govoplan-services",
|
||||
"revision": "b9c2d3e4f5a6"
|
||||
},
|
||||
{
|
||||
"owner": "govoplan-audit",
|
||||
"revision": "b9e2f5a8c3d6"
|
||||
},
|
||||
{
|
||||
"owner": "govoplan-parties",
|
||||
"revision": "c0d3e4f5a6b7"
|
||||
},
|
||||
{
|
||||
"owner": "govoplan-connectors",
|
||||
"revision": "c0f1a2b3c4d5"
|
||||
},
|
||||
{
|
||||
"owner": "govoplan-idm",
|
||||
"revision": "c2d3e4f5a6b7"
|
||||
},
|
||||
{
|
||||
"owner": "govoplan-identity-trust",
|
||||
"revision": "c3f5a7b9d1e2"
|
||||
},
|
||||
{
|
||||
"owner": "govoplan-projects",
|
||||
"revision": "c4a1e8f2d6b9"
|
||||
},
|
||||
{
|
||||
"owner": "govoplan-core",
|
||||
"revision": "c58a2d7e9f10"
|
||||
},
|
||||
{
|
||||
"owner": "govoplan-views",
|
||||
"revision": "c6f2a9d4e7b1"
|
||||
},
|
||||
{
|
||||
"owner": "govoplan-reporting",
|
||||
"revision": "c8d5e2f6a9b3"
|
||||
},
|
||||
{
|
||||
"owner": "govoplan-datasources",
|
||||
"revision": "d1a7c3e9f5b2"
|
||||
},
|
||||
{
|
||||
"owner": "govoplan-decisions",
|
||||
"revision": "d1e4f5a6b7c8"
|
||||
},
|
||||
{
|
||||
"owner": "govoplan-calendar",
|
||||
"revision": "d24e5f607182"
|
||||
},
|
||||
{
|
||||
"owner": "govoplan-docs",
|
||||
"revision": "d3e7a1c5f9b2"
|
||||
},
|
||||
{
|
||||
"owner": "govoplan-forms-runtime",
|
||||
"revision": "d6a8b0c2e4f6"
|
||||
},
|
||||
{
|
||||
"owner": "govoplan-addresses",
|
||||
"revision": "d6e8f9a0b1c2"
|
||||
},
|
||||
{
|
||||
"owner": "govoplan-scheduling",
|
||||
"revision": "d7a4c1e8f205"
|
||||
},
|
||||
{
|
||||
"owner": "govoplan-postbox",
|
||||
"revision": "d8b4f1a6c9e2"
|
||||
},
|
||||
{
|
||||
"owner": "govoplan-committee",
|
||||
"revision": "d8b9f0a1c2e3"
|
||||
},
|
||||
{
|
||||
"owner": "govoplan-access",
|
||||
"revision": "d8f1b4e7a0c3"
|
||||
},
|
||||
{
|
||||
"owner": "govoplan-encryption",
|
||||
"revision": "e5b7c9d1f3a4"
|
||||
},
|
||||
{
|
||||
"owner": "govoplan-payments",
|
||||
"revision": "e7b9c1d3f5a7"
|
||||
},
|
||||
{
|
||||
"owner": "govoplan-dist-lists",
|
||||
"revision": "e7c3a9d1b5f2"
|
||||
},
|
||||
{
|
||||
"owner": "govoplan-campaign",
|
||||
"revision": "f3c7a9d2e6b1"
|
||||
}
|
||||
],
|
||||
"owner_heads": [
|
||||
{
|
||||
"owner": "govoplan-access",
|
||||
"revisions": [
|
||||
"d8f1b4e7a0c3"
|
||||
]
|
||||
},
|
||||
{
|
||||
"owner": "govoplan-addresses",
|
||||
"revisions": [
|
||||
"d6e8f9a0b1c2"
|
||||
]
|
||||
},
|
||||
{
|
||||
"owner": "govoplan-approvals",
|
||||
"revisions": [
|
||||
"a91c4e72b5d8"
|
||||
]
|
||||
},
|
||||
{
|
||||
"owner": "govoplan-audit",
|
||||
"revisions": [
|
||||
"b9e2f5a8c3d6"
|
||||
]
|
||||
},
|
||||
{
|
||||
"owner": "govoplan-calendar",
|
||||
"revisions": [
|
||||
"d24e5f607182"
|
||||
]
|
||||
},
|
||||
{
|
||||
"owner": "govoplan-campaign",
|
||||
"revisions": [
|
||||
"f3c7a9d2e6b1"
|
||||
]
|
||||
},
|
||||
{
|
||||
"owner": "govoplan-cases",
|
||||
"revisions": [
|
||||
"a7c4e2f9b1d6"
|
||||
]
|
||||
},
|
||||
{
|
||||
"owner": "govoplan-committee",
|
||||
"revisions": [
|
||||
"d8b9f0a1c2e3"
|
||||
]
|
||||
},
|
||||
{
|
||||
"owner": "govoplan-connectors",
|
||||
"revisions": [
|
||||
"c0f1a2b3c4d5"
|
||||
]
|
||||
},
|
||||
{
|
||||
"owner": "govoplan-core",
|
||||
"revisions": [
|
||||
"c58a2d7e9f10"
|
||||
]
|
||||
},
|
||||
{
|
||||
"owner": "govoplan-dashboard",
|
||||
"revisions": [
|
||||
"7b9d2f4a6c8e"
|
||||
]
|
||||
},
|
||||
{
|
||||
"owner": "govoplan-dataflow",
|
||||
"revisions": [
|
||||
"a3d7f1c5e9b2"
|
||||
]
|
||||
},
|
||||
{
|
||||
"owner": "govoplan-datasources",
|
||||
"revisions": [
|
||||
"d1a7c3e9f5b2"
|
||||
]
|
||||
},
|
||||
{
|
||||
"owner": "govoplan-decisions",
|
||||
"revisions": [
|
||||
"d1e4f5a6b7c8"
|
||||
]
|
||||
},
|
||||
{
|
||||
"owner": "govoplan-dist-lists",
|
||||
"revisions": [
|
||||
"e7c3a9d1b5f2"
|
||||
]
|
||||
},
|
||||
{
|
||||
"owner": "govoplan-docs",
|
||||
"revisions": [
|
||||
"d3e7a1c5f9b2"
|
||||
]
|
||||
},
|
||||
{
|
||||
"owner": "govoplan-encryption",
|
||||
"revisions": [
|
||||
"e5b7c9d1f3a4"
|
||||
]
|
||||
},
|
||||
{
|
||||
"owner": "govoplan-files",
|
||||
"revisions": [
|
||||
"a2b3c4d5e6f8",
|
||||
"a2b3c4d5e6f9"
|
||||
]
|
||||
},
|
||||
{
|
||||
"owner": "govoplan-forms",
|
||||
"revisions": [
|
||||
"e1f2a3b4c5d6"
|
||||
]
|
||||
},
|
||||
{
|
||||
"owner": "govoplan-forms-runtime",
|
||||
"revisions": [
|
||||
"d6a8b0c2e4f6"
|
||||
]
|
||||
},
|
||||
{
|
||||
"owner": "govoplan-helpdesk",
|
||||
"revisions": [
|
||||
"9e2a5c8f1b4d"
|
||||
]
|
||||
},
|
||||
{
|
||||
"owner": "govoplan-identity",
|
||||
"revisions": [
|
||||
"5c6d7e8f9a10"
|
||||
]
|
||||
},
|
||||
{
|
||||
"owner": "govoplan-identity-trust",
|
||||
"revisions": [
|
||||
"c3f5a7b9d1e2"
|
||||
]
|
||||
},
|
||||
{
|
||||
"owner": "govoplan-idm",
|
||||
"revisions": [
|
||||
"c2d3e4f5a6b7"
|
||||
]
|
||||
},
|
||||
{
|
||||
"owner": "govoplan-mail",
|
||||
"revisions": [
|
||||
"a4c5d6e7f809"
|
||||
]
|
||||
},
|
||||
{
|
||||
"owner": "govoplan-mandates",
|
||||
"revisions": [
|
||||
"a8b1c2d3e4f5"
|
||||
]
|
||||
},
|
||||
{
|
||||
"owner": "govoplan-notifications",
|
||||
"revisions": [
|
||||
"6e2f91ab4c70"
|
||||
]
|
||||
},
|
||||
{
|
||||
"owner": "govoplan-organizations",
|
||||
"revisions": [
|
||||
"a61e4d9c72b8"
|
||||
]
|
||||
},
|
||||
{
|
||||
"owner": "govoplan-parties",
|
||||
"revisions": [
|
||||
"c0d3e4f5a6b7"
|
||||
]
|
||||
},
|
||||
{
|
||||
"owner": "govoplan-payments",
|
||||
"revisions": [
|
||||
"e7b9c1d3f5a7"
|
||||
]
|
||||
},
|
||||
{
|
||||
"owner": "govoplan-policy",
|
||||
"revisions": [
|
||||
"a9c4e7b2d5f8"
|
||||
]
|
||||
},
|
||||
{
|
||||
"owner": "govoplan-poll",
|
||||
"revisions": [
|
||||
"6e7f8a9b0c1d"
|
||||
]
|
||||
},
|
||||
{
|
||||
"owner": "govoplan-postbox",
|
||||
"revisions": [
|
||||
"d8b4f1a6c9e2"
|
||||
]
|
||||
},
|
||||
{
|
||||
"owner": "govoplan-projects",
|
||||
"revisions": [
|
||||
"c4a1e8f2d6b9"
|
||||
]
|
||||
},
|
||||
{
|
||||
"owner": "govoplan-quick-access",
|
||||
"revisions": [
|
||||
"9a4e6c2d8f10"
|
||||
]
|
||||
},
|
||||
{
|
||||
"owner": "govoplan-records",
|
||||
"revisions": [
|
||||
"8a6c4e2f1b3d"
|
||||
]
|
||||
},
|
||||
{
|
||||
"owner": "govoplan-reporting",
|
||||
"revisions": [
|
||||
"c8d5e2f6a9b3"
|
||||
]
|
||||
},
|
||||
{
|
||||
"owner": "govoplan-risk-compliance",
|
||||
"revisions": [
|
||||
"b9c0d1e2f3a4"
|
||||
]
|
||||
},
|
||||
{
|
||||
"owner": "govoplan-scheduling",
|
||||
"revisions": [
|
||||
"d7a4c1e8f205"
|
||||
]
|
||||
},
|
||||
{
|
||||
"owner": "govoplan-search",
|
||||
"revisions": [
|
||||
"b2c3d4e5f607"
|
||||
]
|
||||
},
|
||||
{
|
||||
"owner": "govoplan-services",
|
||||
"revisions": [
|
||||
"b9c2d3e4f5a6"
|
||||
]
|
||||
},
|
||||
{
|
||||
"owner": "govoplan-tasks",
|
||||
"revisions": [
|
||||
"7c4d9a2e1f30"
|
||||
]
|
||||
},
|
||||
{
|
||||
"owner": "govoplan-templates",
|
||||
"revisions": [
|
||||
"a3f7c9d2e1b4"
|
||||
]
|
||||
},
|
||||
{
|
||||
"owner": "govoplan-tenancy",
|
||||
"revisions": [
|
||||
"b3d8e1f4a6c2"
|
||||
]
|
||||
},
|
||||
{
|
||||
"owner": "govoplan-tickets",
|
||||
"revisions": [
|
||||
"8d1f4b7a2c5e"
|
||||
]
|
||||
},
|
||||
{
|
||||
"owner": "govoplan-views",
|
||||
"revisions": [
|
||||
"c6f2a9d4e7b1"
|
||||
]
|
||||
},
|
||||
{
|
||||
"owner": "govoplan-voting",
|
||||
"revisions": [
|
||||
"8b9c0d1e2f3a"
|
||||
]
|
||||
},
|
||||
{
|
||||
"owner": "govoplan-wiki",
|
||||
"revisions": [
|
||||
"a7c2e9f4b1d6"
|
||||
]
|
||||
},
|
||||
{
|
||||
"owner": "govoplan-workflow-engine",
|
||||
"revisions": [
|
||||
"8d5a2f7c1b4e"
|
||||
]
|
||||
}
|
||||
],
|
||||
"recorded_at": "2026-09-07T23:29:20Z",
|
||||
"release": "0.1.45",
|
||||
"squash_policy": "reviewed-manual",
|
||||
"track": "release"
|
||||
}
|
||||
],
|
||||
"version": 1
|
||||
|
||||
+1
-1
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
||||
|
||||
[project]
|
||||
name = "govoplan-core"
|
||||
version = "0.1.40"
|
||||
version = "0.1.45"
|
||||
description = "Reusable GovOPlaN platform core, access, tenancy, and RBAC components."
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.12"
|
||||
|
||||
@@ -95,6 +95,13 @@ class TenantMembershipInfo(TenantInfo):
|
||||
is_active: bool = True
|
||||
|
||||
|
||||
class NavigationSeparatorPayload(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
id: str = Field(pattern=r"^separator:[a-zA-Z0-9_.:-]+$", max_length=255)
|
||||
label: str = Field(default="", max_length=120, pattern=r"^[^\x00-\x1f]*$")
|
||||
|
||||
|
||||
class NavigationPreferencesPayload(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
@@ -102,6 +109,7 @@ class NavigationPreferencesPayload(BaseModel):
|
||||
order: list[str] = Field(default_factory=list, max_length=256)
|
||||
hidden: list[str] = Field(default_factory=list, max_length=256)
|
||||
locked: list[str] = Field(default_factory=list, max_length=256)
|
||||
separators: list[NavigationSeparatorPayload] | None = Field(default=None, max_length=256)
|
||||
|
||||
|
||||
class AppearanceModeOverrides(BaseModel):
|
||||
|
||||
@@ -780,8 +780,9 @@ def send_email(self, job_id: str):
|
||||
"""Send one explicitly queued campaign job.
|
||||
|
||||
SMTP failures are persisted but are not retried implicitly. A worker-loss
|
||||
redelivery is safe because the delivery service converts an unfinished
|
||||
SMTP attempt into ``outcome_unknown`` instead of transmitting again.
|
||||
redelivery leaves an active delivery claim unchanged instead of transmitting
|
||||
again. Explicit fenced recovery requires stopped-owner evidence before an
|
||||
abandoned attempt can become ``outcome_unknown`` for reconciliation.
|
||||
"""
|
||||
|
||||
from govoplan_core.db.session import get_database
|
||||
|
||||
@@ -371,6 +371,24 @@ def _approval_count(request: dict[str, Any]) -> int:
|
||||
|
||||
|
||||
def _sanitize_value(key: str, value: object) -> object:
|
||||
if key == "campaign_archive_encryption_policy":
|
||||
if not isinstance(value, dict):
|
||||
return "<redacted>"
|
||||
# These are format/channel names, never passwords. Preserve only the
|
||||
# exact public enum lists so rollback history remains useful without
|
||||
# exempting arbitrary password-named fields from secret redaction.
|
||||
allowed_values = {
|
||||
"allowed_password_encryption_methods": frozenset({"aes", "zip_standard"}),
|
||||
"allowed_password_delivery_channels": frozenset({"separate_mail", "sms", "letter", "phone", "in_person"}),
|
||||
}
|
||||
return {
|
||||
name: list(items)
|
||||
if name in allowed_values
|
||||
and isinstance(items, list)
|
||||
and all(isinstance(item, str) and item in allowed_values[name] for item in items)
|
||||
else "<redacted>"
|
||||
for name, items in value.items()
|
||||
}
|
||||
field = classify_configuration_field(key)
|
||||
if field is not None and field.secret_handling in {"reference_only", "env_only"}:
|
||||
return _redact_secrets(value)
|
||||
|
||||
@@ -107,6 +107,20 @@ class _ConfigurationChangeSafetyState:
|
||||
|
||||
|
||||
_CONFIGURATION_FIELD_SAFETY: tuple[ConfigurationFieldSafety, ...] = (
|
||||
ConfigurationFieldSafety(
|
||||
key="campaign_delivery_policy.system", label="System Campaign synchronous delivery limit",
|
||||
owner_module="campaigns", scope="system", storage="system_settings", ui_managed=True,
|
||||
risk="medium", required_scopes=("system:settings:write",),
|
||||
audit_event="campaign.delivery_policy_updated", rollback_history_required=True,
|
||||
notes="A bounded 0–500 recipient-job maximum for one interactive Send now request. Explicit deployment ceilings remain authoritative; saving never delivers mail or changes review evidence.",
|
||||
),
|
||||
ConfigurationFieldSafety(
|
||||
key="campaign_delivery_policy.tenant", label="Tenant Campaign synchronous delivery limit",
|
||||
owner_module="campaigns", scope="tenant", storage="tenant_settings", ui_managed=True,
|
||||
risk="medium", required_scopes=("admin:policies:write",),
|
||||
audit_event="campaign.delivery_policy_updated", rollback_history_required=True,
|
||||
notes="Tenant policy may only narrow the inherited system/deployment recipient-job maximum; clearing an override restores inheritance. Changes retain before/after history.",
|
||||
),
|
||||
ConfigurationFieldSafety(
|
||||
key="module_management.desired_enabled",
|
||||
label="Enabled modules",
|
||||
@@ -171,6 +185,21 @@ _CONFIGURATION_FIELD_SAFETY: tuple[ConfigurationFieldSafety, ...] = (
|
||||
rollback_history_required=True,
|
||||
notes="Maintenance mode controls platform availability and gates dangerous operations.",
|
||||
),
|
||||
ConfigurationFieldSafety(
|
||||
key="campaign_archive_encryption_policy",
|
||||
label="Campaign archive encryption policy",
|
||||
owner_module="policy",
|
||||
scope="system",
|
||||
storage="policy_overrides",
|
||||
ui_managed=True,
|
||||
risk="high",
|
||||
required_scopes=("system:settings:write", "admin:policies:write"),
|
||||
validation_required=True,
|
||||
policy_explanation_required=True,
|
||||
audit_event="campaign_archive_encryption_policy.updated",
|
||||
rollback_history_required=True,
|
||||
notes="Explicit system ceiling for Campaign archive methods and separate password-delivery channels. Policy validates allowed values and retains before/after history; lower scopes may only narrow. Legacy use additionally requires the dedicated Campaign permission and reasoned weak-encryption acknowledgement, so saving policy alone never enables or sends an archive.",
|
||||
),
|
||||
ConfigurationFieldSafety(
|
||||
key="privacy_retention_policy",
|
||||
label="Privacy retention policy",
|
||||
|
||||
@@ -2,14 +2,18 @@ from __future__ import annotations
|
||||
|
||||
from collections.abc import Mapping
|
||||
from dataclasses import dataclass
|
||||
from datetime import UTC, datetime
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import re
|
||||
from typing import Any
|
||||
from typing import Any, Protocol, runtime_checkable
|
||||
|
||||
|
||||
DEPLOYMENT_CAPABILITIES_ENV = "GOVOPLAN_DEPLOYMENT_CAPABILITIES_PATH"
|
||||
INFRASTRUCTURE_DEPENDENCY_PROVIDER_CAPABILITY_PREFIX = (
|
||||
"infrastructure.dependency_inventory."
|
||||
)
|
||||
MAX_CAPABILITY_DOCUMENT_BYTES = 256 * 1024
|
||||
CAPABILITY_STATES = frozenset(
|
||||
{
|
||||
@@ -20,12 +24,125 @@ CAPABILITY_STATES = frozenset(
|
||||
}
|
||||
)
|
||||
_ENV_REFERENCE_RE = re.compile(r"^env:[A-Za-z_][A-Za-z0-9_]*$")
|
||||
_DEPENDENCY_STATES = frozenset(
|
||||
{"active", "inactive", "data_present", "pending_work", "runtime_binding"}
|
||||
)
|
||||
|
||||
|
||||
class InfrastructureCapabilityReceiptError(ValueError):
|
||||
pass
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class InfrastructureDependency:
|
||||
"""A non-secret module-owned dependency on deployment infrastructure."""
|
||||
|
||||
capability_id: str
|
||||
module_id: str
|
||||
dependency_type: str
|
||||
dependency_ref: str
|
||||
state: str
|
||||
scope: str
|
||||
summary: str
|
||||
metrics: Mapping[str, int]
|
||||
required_action: str
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
for field_name, value, maximum in (
|
||||
("capability_id", self.capability_id, 120),
|
||||
("module_id", self.module_id, 120),
|
||||
("dependency_type", self.dependency_type, 120),
|
||||
("dependency_ref", self.dependency_ref, 240),
|
||||
("scope", self.scope, 120),
|
||||
("summary", self.summary, 1000),
|
||||
("required_action", self.required_action, 1000),
|
||||
):
|
||||
if (
|
||||
not value.strip()
|
||||
or len(value) > maximum
|
||||
or any(ord(char) < 32 for char in value)
|
||||
):
|
||||
raise ValueError(
|
||||
f"Infrastructure dependency {field_name} is invalid."
|
||||
)
|
||||
if self.state not in _DEPENDENCY_STATES:
|
||||
raise ValueError(
|
||||
f"Infrastructure dependency state is unsupported: {self.state!r}."
|
||||
)
|
||||
if len(self.metrics) > 20 or any(
|
||||
not isinstance(key, str)
|
||||
or not key.strip()
|
||||
or len(key) > 80
|
||||
or any(ord(char) < 32 for char in key)
|
||||
or type(value) is not int
|
||||
or value < 0
|
||||
for key, value in self.metrics.items()
|
||||
):
|
||||
raise ValueError("Infrastructure dependency metrics are invalid.")
|
||||
|
||||
def to_dict(self) -> dict[str, object]:
|
||||
return {
|
||||
"capability_id": self.capability_id,
|
||||
"module_id": self.module_id,
|
||||
"dependency_type": self.dependency_type,
|
||||
"dependency_ref": self.dependency_ref,
|
||||
"state": self.state,
|
||||
"scope": self.scope,
|
||||
"summary": self.summary,
|
||||
"metrics": dict(sorted(self.metrics.items())),
|
||||
"required_action": self.required_action,
|
||||
}
|
||||
|
||||
|
||||
@runtime_checkable
|
||||
class InfrastructureDependencyProvider(Protocol):
|
||||
module_id: str
|
||||
capability_ids: tuple[str, ...]
|
||||
|
||||
def infrastructure_dependencies(self) -> tuple[InfrastructureDependency, ...]:
|
||||
...
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class InfrastructureDependencyProviderReport:
|
||||
module_id: str
|
||||
capability_ids: tuple[str, ...]
|
||||
state: str
|
||||
dependency_count: int
|
||||
error: str | None = None
|
||||
|
||||
def to_dict(self) -> dict[str, object]:
|
||||
return {
|
||||
"module_id": self.module_id,
|
||||
"capability_ids": list(self.capability_ids),
|
||||
"state": self.state,
|
||||
"dependency_count": self.dependency_count,
|
||||
"error": self.error,
|
||||
}
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class InfrastructureDependencyInventory:
|
||||
installation_id: str
|
||||
generated_at: str
|
||||
complete: bool
|
||||
inspected_capability_ids: tuple[str, ...]
|
||||
providers: tuple[InfrastructureDependencyProviderReport, ...]
|
||||
dependencies: tuple[InfrastructureDependency, ...]
|
||||
schema_version: int = 1
|
||||
|
||||
def to_dict(self) -> dict[str, object]:
|
||||
return {
|
||||
"schema_version": self.schema_version,
|
||||
"installation_id": self.installation_id,
|
||||
"generated_at": self.generated_at,
|
||||
"complete": self.complete,
|
||||
"inspected_capability_ids": list(self.inspected_capability_ids),
|
||||
"providers": [item.to_dict() for item in self.providers],
|
||||
"dependencies": [item.to_dict() for item in self.dependencies],
|
||||
}
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class InfrastructureCapability:
|
||||
id: str
|
||||
@@ -214,6 +331,134 @@ def deployment_capability_status(
|
||||
}
|
||||
|
||||
|
||||
def collect_infrastructure_dependency_inventory(
|
||||
registry: object,
|
||||
*,
|
||||
installation_id: str,
|
||||
observed_at: datetime | None = None,
|
||||
) -> InfrastructureDependencyInventory:
|
||||
"""Collect actual module-owned dependencies without importing module internals."""
|
||||
|
||||
normalized_installation_id = installation_id.strip()
|
||||
if not normalized_installation_id or len(normalized_installation_id) > 100:
|
||||
raise ValueError("Infrastructure dependency installation id is invalid.")
|
||||
capability_names = getattr(registry, "capability_names", None)
|
||||
capability = getattr(registry, "capability", None)
|
||||
if not callable(capability_names) or not callable(capability):
|
||||
raise ValueError("Infrastructure dependency inventory requires a module registry.")
|
||||
|
||||
provider_names = tuple(
|
||||
name
|
||||
for name in capability_names()
|
||||
if isinstance(name, str)
|
||||
and name.startswith(INFRASTRUCTURE_DEPENDENCY_PROVIDER_CAPABILITY_PREFIX)
|
||||
)
|
||||
reports: list[InfrastructureDependencyProviderReport] = []
|
||||
dependencies: list[InfrastructureDependency] = []
|
||||
inspected_capability_ids: set[str] = set()
|
||||
complete = True
|
||||
|
||||
for provider_name in sorted(provider_names):
|
||||
expected_module_id = provider_name.removeprefix(
|
||||
INFRASTRUCTURE_DEPENDENCY_PROVIDER_CAPABILITY_PREFIX
|
||||
)
|
||||
module_id = expected_module_id or "unknown"
|
||||
declared_ids: tuple[str, ...] = ()
|
||||
try:
|
||||
provider = capability(provider_name)
|
||||
if not isinstance(provider, InfrastructureDependencyProvider):
|
||||
raise TypeError("provider does not implement the inventory contract")
|
||||
module_id = provider.module_id.strip()
|
||||
declared_ids = tuple(
|
||||
sorted(
|
||||
{
|
||||
item.strip()
|
||||
for item in provider.capability_ids
|
||||
if isinstance(item, str) and item.strip()
|
||||
}
|
||||
)
|
||||
)
|
||||
if (
|
||||
module_id != expected_module_id
|
||||
or len(module_id) > 120
|
||||
or any(ord(char) < 32 for char in module_id)
|
||||
or not declared_ids
|
||||
or len(declared_ids) > 30
|
||||
or any(
|
||||
len(item) > 120 or any(ord(char) < 32 for char in item)
|
||||
for item in declared_ids
|
||||
)
|
||||
):
|
||||
raise ValueError("provider identity or capability declaration is invalid")
|
||||
provider_dependencies = tuple(provider.infrastructure_dependencies())
|
||||
if len(provider_dependencies) > 10_000:
|
||||
raise ValueError("provider dependency inventory is too large")
|
||||
seen_refs: set[tuple[str, str, str]] = set()
|
||||
for item in provider_dependencies:
|
||||
if not isinstance(item, InfrastructureDependency):
|
||||
raise TypeError("provider returned an invalid dependency")
|
||||
if item.module_id != module_id or item.capability_id not in declared_ids:
|
||||
raise ValueError("provider returned a dependency outside its declaration")
|
||||
identity = (
|
||||
item.capability_id,
|
||||
item.dependency_type,
|
||||
item.dependency_ref,
|
||||
)
|
||||
if identity in seen_refs:
|
||||
raise ValueError("provider returned a duplicate dependency")
|
||||
seen_refs.add(identity)
|
||||
if len(dependencies) + len(provider_dependencies) > 10_000:
|
||||
raise ValueError("combined dependency inventory is too large")
|
||||
dependencies.extend(provider_dependencies)
|
||||
inspected_capability_ids.update(declared_ids)
|
||||
reports.append(
|
||||
InfrastructureDependencyProviderReport(
|
||||
module_id=module_id,
|
||||
capability_ids=declared_ids,
|
||||
state="complete",
|
||||
dependency_count=len(provider_dependencies),
|
||||
)
|
||||
)
|
||||
except Exception as exc:
|
||||
complete = False
|
||||
inspected_capability_ids.update(declared_ids)
|
||||
reports.append(
|
||||
InfrastructureDependencyProviderReport(
|
||||
module_id=module_id,
|
||||
capability_ids=declared_ids,
|
||||
state="error",
|
||||
dependency_count=0,
|
||||
error=(
|
||||
f"{type(exc).__name__}: provider inventory could not be completed"
|
||||
),
|
||||
)
|
||||
)
|
||||
|
||||
timestamp = observed_at or datetime.now(UTC)
|
||||
if timestamp.tzinfo is None:
|
||||
timestamp = timestamp.replace(tzinfo=UTC)
|
||||
return InfrastructureDependencyInventory(
|
||||
installation_id=normalized_installation_id,
|
||||
generated_at=timestamp.astimezone(UTC).isoformat(),
|
||||
complete=complete,
|
||||
inspected_capability_ids=tuple(sorted(inspected_capability_ids)),
|
||||
providers=tuple(
|
||||
sorted(reports, key=lambda item: (item.module_id, item.capability_ids))
|
||||
),
|
||||
dependencies=tuple(
|
||||
sorted(
|
||||
dependencies,
|
||||
key=lambda item: (
|
||||
item.capability_id,
|
||||
item.module_id,
|
||||
item.dependency_type,
|
||||
item.dependency_ref,
|
||||
),
|
||||
)
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def _capability(value: object) -> InfrastructureCapability:
|
||||
if not isinstance(value, Mapping):
|
||||
raise InfrastructureCapabilityReceiptError(
|
||||
@@ -352,10 +597,16 @@ def _unavailable_status(*, configured: bool, error: str | None) -> dict[str, obj
|
||||
__all__ = [
|
||||
"CAPABILITY_STATES",
|
||||
"DEPLOYMENT_CAPABILITIES_ENV",
|
||||
"INFRASTRUCTURE_DEPENDENCY_PROVIDER_CAPABILITY_PREFIX",
|
||||
"InfrastructureCapability",
|
||||
"InfrastructureCapabilityReceipt",
|
||||
"InfrastructureCapabilityReceiptError",
|
||||
"InfrastructureDependency",
|
||||
"InfrastructureDependencyInventory",
|
||||
"InfrastructureDependencyProvider",
|
||||
"InfrastructureDependencyProviderReport",
|
||||
"InfrastructurePostInstallTask",
|
||||
"collect_infrastructure_dependency_inventory",
|
||||
"deployment_capability_status",
|
||||
"infrastructure_capability_receipt_from_mapping",
|
||||
"load_infrastructure_capability_receipt",
|
||||
|
||||
@@ -8,11 +8,22 @@ NAVIGATION_PREFERENCES_CONTRACT_VERSION = "1"
|
||||
_MAX_ITEMS = 256
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class NavigationSeparator:
|
||||
id: str
|
||||
label: str = ""
|
||||
|
||||
def as_dict(self) -> dict[str, str]:
|
||||
return {"id": self.id, "label": self.label}
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class NavigationPreferences:
|
||||
order: tuple[str, ...] = ()
|
||||
hidden: tuple[str, ...] = ()
|
||||
locked: tuple[str, ...] = ()
|
||||
# None preserves inherited grouping; an empty tuple explicitly removes it.
|
||||
separators: tuple[NavigationSeparator, ...] | None = None
|
||||
|
||||
def as_dict(self) -> dict[str, object]:
|
||||
return {
|
||||
@@ -20,6 +31,7 @@ class NavigationPreferences:
|
||||
"order": list(self.order),
|
||||
"hidden": list(self.hidden),
|
||||
"locked": list(self.locked),
|
||||
**({"separators": [item.as_dict() for item in self.separators]} if self.separators is not None else {}),
|
||||
}
|
||||
|
||||
|
||||
@@ -32,6 +44,9 @@ class EffectiveNavigationItem:
|
||||
order_source: str
|
||||
visibility_source: str
|
||||
lock_source: str | None = None
|
||||
section: NavigationSeparator | None = None
|
||||
custom_layout: bool = False
|
||||
layout_source: str = "module"
|
||||
|
||||
def as_dict(self) -> dict[str, object]:
|
||||
return {
|
||||
@@ -42,6 +57,9 @@ class EffectiveNavigationItem:
|
||||
"navigation_order_source": self.order_source,
|
||||
"navigation_visibility_source": self.visibility_source,
|
||||
"navigation_lock_source": self.lock_source,
|
||||
"navigation_section": self.section.as_dict() if self.section else None,
|
||||
"navigation_custom_layout": self.custom_layout,
|
||||
"navigation_layout_source": self.layout_source,
|
||||
}
|
||||
|
||||
|
||||
@@ -63,6 +81,7 @@ def navigation_preferences_from_mapping(
|
||||
order=_ids(raw.get("order")),
|
||||
hidden=_ids(raw.get("hidden")),
|
||||
locked=_ids(raw.get("locked")),
|
||||
separators=_separators(raw.get("separators")),
|
||||
)
|
||||
|
||||
|
||||
@@ -95,6 +114,9 @@ def resolve_navigation_preferences(
|
||||
visibility = {item_id: True for item_id in ordered}
|
||||
visibility_source = {item_id: "module" for item_id in ordered}
|
||||
locks: dict[str, str] = {}
|
||||
separators: dict[str, NavigationSeparator] = {}
|
||||
custom_layout = False
|
||||
layout_source = "module"
|
||||
|
||||
for source, preferences, may_lock in (
|
||||
("system", system, True),
|
||||
@@ -103,7 +125,13 @@ def resolve_navigation_preferences(
|
||||
):
|
||||
if preferences is None:
|
||||
continue
|
||||
requested_order = [item_id for item_id in preferences.order if item_id in available]
|
||||
if preferences.separators is not None:
|
||||
separators = {item.id: item for item in preferences.separators if item.id not in available}
|
||||
ordered = [item_id for item_id in ordered if item_id in available or item_id in separators]
|
||||
ordered.extend(item_id for item_id in separators if item_id not in ordered)
|
||||
custom_layout = True
|
||||
layout_source = source
|
||||
requested_order = list(dict.fromkeys(item_id for item_id in preferences.order if item_id in available or item_id in separators))
|
||||
if requested_order:
|
||||
requested = set(requested_order)
|
||||
ordered = [*requested_order, *(item_id for item_id in ordered if item_id not in requested)]
|
||||
@@ -127,8 +155,13 @@ def resolve_navigation_preferences(
|
||||
visibility[item_id] = True
|
||||
visibility_source[item_id] = source
|
||||
|
||||
return {
|
||||
item_id: EffectiveNavigationItem(
|
||||
result: dict[str, EffectiveNavigationItem] = {}
|
||||
section: NavigationSeparator | None = None
|
||||
for index, item_id in enumerate(ordered):
|
||||
if item_id in separators:
|
||||
section = separators[item_id]
|
||||
continue
|
||||
result[item_id] = EffectiveNavigationItem(
|
||||
id=item_id,
|
||||
order=index,
|
||||
visible=visibility[item_id],
|
||||
@@ -136,9 +169,29 @@ def resolve_navigation_preferences(
|
||||
order_source=order_source[item_id],
|
||||
visibility_source=visibility_source[item_id],
|
||||
lock_source=locks.get(item_id),
|
||||
section=section,
|
||||
custom_layout=custom_layout,
|
||||
layout_source=layout_source,
|
||||
)
|
||||
for index, item_id in enumerate(ordered)
|
||||
}
|
||||
return result
|
||||
|
||||
|
||||
def _separators(value: object) -> tuple[NavigationSeparator, ...] | None:
|
||||
if not isinstance(value, (list, tuple)):
|
||||
return None
|
||||
items: dict[str, NavigationSeparator] = {}
|
||||
for raw in value[:_MAX_ITEMS]:
|
||||
if not isinstance(raw, Mapping):
|
||||
continue
|
||||
item_id = _clean_id(raw.get("id"))
|
||||
label = raw.get("label", "")
|
||||
if not item_id.startswith("separator:") or not isinstance(label, str):
|
||||
continue
|
||||
label = label.strip()[:120]
|
||||
if any(ord(character) < 32 for character in label):
|
||||
continue
|
||||
items[item_id] = NavigationSeparator(item_id, label)
|
||||
return tuple(items.values())
|
||||
|
||||
|
||||
def _ids(value: object) -> tuple[str, ...]:
|
||||
@@ -168,6 +221,7 @@ __all__ = [
|
||||
"NAVIGATION_PREFERENCES_CONTRACT_VERSION",
|
||||
"NAVIGATION_PREFERENCES_KEY",
|
||||
"NavigationPreferences",
|
||||
"NavigationSeparator",
|
||||
"navigation_preferences_from_mapping",
|
||||
"navigation_preferences_from_settings",
|
||||
"resolve_navigation_preferences",
|
||||
|
||||
@@ -0,0 +1,467 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Mapping
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import UTC, datetime
|
||||
from typing import Literal, Protocol, runtime_checkable
|
||||
|
||||
|
||||
TENANT_ERASURE_PROVIDER_CAPABILITY_PREFIX = "tenancy.erasure_provider."
|
||||
|
||||
TenantErasureDisposition = Literal[
|
||||
"erase",
|
||||
"retain",
|
||||
"legal_hold",
|
||||
"external_cleanup",
|
||||
"key_destroy",
|
||||
"backup_expiry",
|
||||
"unavailable",
|
||||
]
|
||||
TenantErasureStepKind = Literal[
|
||||
"export",
|
||||
"erase",
|
||||
"retain",
|
||||
"external_cleanup",
|
||||
"key_destroy",
|
||||
"backup_expiry",
|
||||
"verify",
|
||||
]
|
||||
TenantErasureResultState = Literal[
|
||||
"completed",
|
||||
"pending",
|
||||
"blocked",
|
||||
"outcome_unknown",
|
||||
]
|
||||
|
||||
_DISPOSITIONS = frozenset(
|
||||
{
|
||||
"erase",
|
||||
"retain",
|
||||
"legal_hold",
|
||||
"external_cleanup",
|
||||
"key_destroy",
|
||||
"backup_expiry",
|
||||
"unavailable",
|
||||
}
|
||||
)
|
||||
_STEP_KINDS = frozenset(
|
||||
{
|
||||
"export",
|
||||
"erase",
|
||||
"retain",
|
||||
"external_cleanup",
|
||||
"key_destroy",
|
||||
"backup_expiry",
|
||||
"verify",
|
||||
}
|
||||
)
|
||||
_RESULT_STATES = frozenset(
|
||||
{"completed", "pending", "blocked", "outcome_unknown"}
|
||||
)
|
||||
|
||||
|
||||
def _text(value: str, label: str, *, maximum: int) -> str:
|
||||
normalized = value.strip()
|
||||
if (
|
||||
not normalized
|
||||
or len(normalized) > maximum
|
||||
or any(ord(character) < 32 for character in normalized)
|
||||
):
|
||||
raise ValueError(f"Tenant erasure {label} is invalid.")
|
||||
return normalized
|
||||
|
||||
|
||||
def _texts(
|
||||
values: tuple[str, ...],
|
||||
label: str,
|
||||
*,
|
||||
maximum_items: int = 100,
|
||||
maximum_length: int = 500,
|
||||
) -> tuple[str, ...]:
|
||||
if len(values) > maximum_items:
|
||||
raise ValueError(f"Tenant erasure {label} has too many entries.")
|
||||
normalized = tuple(
|
||||
_text(value, label, maximum=maximum_length) for value in values
|
||||
)
|
||||
if len(normalized) != len(set(normalized)):
|
||||
raise ValueError(f"Tenant erasure {label} contains duplicates.")
|
||||
return normalized
|
||||
|
||||
|
||||
def _metrics(values: Mapping[str, int]) -> dict[str, int]:
|
||||
if len(values) > 30:
|
||||
raise ValueError("Tenant erasure metrics has too many entries.")
|
||||
normalized: dict[str, int] = {}
|
||||
for key, value in values.items():
|
||||
normalized_key = _text(key, "metric key", maximum=80)
|
||||
if type(value) is not int or value < 0:
|
||||
raise ValueError("Tenant erasure metric values must be non-negative integers.")
|
||||
normalized[normalized_key] = value
|
||||
return normalized
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class TenantErasureResource:
|
||||
resource_type: str
|
||||
count: int
|
||||
disposition: TenantErasureDisposition
|
||||
summary: str
|
||||
governance_ref: str | None = None
|
||||
external: bool = False
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
_text(self.resource_type, "resource type", maximum=120)
|
||||
_text(self.summary, "resource summary", maximum=1000)
|
||||
if type(self.count) is not int or self.count < 0:
|
||||
raise ValueError("Tenant erasure resource count is invalid.")
|
||||
if self.disposition not in _DISPOSITIONS:
|
||||
raise ValueError("Tenant erasure resource disposition is invalid.")
|
||||
if self.governance_ref is not None:
|
||||
_text(self.governance_ref, "governance reference", maximum=300)
|
||||
|
||||
def to_dict(self) -> dict[str, object]:
|
||||
return {
|
||||
"resource_type": self.resource_type,
|
||||
"count": self.count,
|
||||
"disposition": self.disposition,
|
||||
"summary": self.summary,
|
||||
"governance_ref": self.governance_ref,
|
||||
"external": self.external,
|
||||
}
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class TenantErasureStep:
|
||||
step_id: str
|
||||
kind: TenantErasureStepKind
|
||||
summary: str
|
||||
destructive: bool
|
||||
irreversible: bool
|
||||
requires_reconciliation: bool = False
|
||||
depends_on: tuple[str, ...] = ()
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
_text(self.step_id, "step id", maximum=160)
|
||||
_text(self.summary, "step summary", maximum=1000)
|
||||
if self.kind not in _STEP_KINDS:
|
||||
raise ValueError("Tenant erasure step kind is invalid.")
|
||||
_texts(self.depends_on, "step dependencies", maximum_length=160)
|
||||
if self.step_id in self.depends_on:
|
||||
raise ValueError("Tenant erasure step cannot depend on itself.")
|
||||
if self.irreversible and not self.destructive:
|
||||
raise ValueError("An irreversible tenant erasure step must be destructive.")
|
||||
|
||||
def to_dict(self) -> dict[str, object]:
|
||||
return {
|
||||
"step_id": self.step_id,
|
||||
"kind": self.kind,
|
||||
"summary": self.summary,
|
||||
"destructive": self.destructive,
|
||||
"irreversible": self.irreversible,
|
||||
"requires_reconciliation": self.requires_reconciliation,
|
||||
"depends_on": list(self.depends_on),
|
||||
}
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class TenantErasurePreview:
|
||||
module_id: str
|
||||
complete: bool
|
||||
resources: tuple[TenantErasureResource, ...] = ()
|
||||
steps: tuple[TenantErasureStep, ...] = ()
|
||||
blockers: tuple[str, ...] = ()
|
||||
warnings: tuple[str, ...] = ()
|
||||
provider_revision: str = "1"
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
_text(self.module_id, "module id", maximum=120)
|
||||
_text(self.provider_revision, "provider revision", maximum=120)
|
||||
_texts(self.blockers, "blockers", maximum_length=1000)
|
||||
_texts(self.warnings, "warnings", maximum_length=1000)
|
||||
if len(self.resources) > 500 or len(self.steps) > 500:
|
||||
raise ValueError("Tenant erasure preview is too large.")
|
||||
resource_types = [item.resource_type for item in self.resources]
|
||||
if len(resource_types) != len(set(resource_types)):
|
||||
raise ValueError("Tenant erasure preview repeats a resource type.")
|
||||
resources_requiring_action = tuple(
|
||||
item for item in self.resources if item.count > 0
|
||||
)
|
||||
if resources_requiring_action and not self.steps and not self.blockers:
|
||||
raise ValueError(
|
||||
"Tenant erasure resources require steps or an explicit blocker."
|
||||
)
|
||||
if any(
|
||||
item.count > 0 and item.disposition == "unavailable"
|
||||
for item in self.resources
|
||||
) and not self.blockers:
|
||||
raise ValueError(
|
||||
"Unavailable tenant erasure resources require an explicit blocker."
|
||||
)
|
||||
if not self.complete and not self.blockers:
|
||||
raise ValueError(
|
||||
"An incomplete tenant erasure preview requires an explicit blocker."
|
||||
)
|
||||
step_ids = [item.step_id for item in self.steps]
|
||||
if len(step_ids) != len(set(step_ids)):
|
||||
raise ValueError("Tenant erasure preview repeats a step id.")
|
||||
known_step_ids = set(step_ids)
|
||||
if any(
|
||||
dependency not in known_step_ids
|
||||
for step in self.steps
|
||||
for dependency in step.depends_on
|
||||
):
|
||||
raise ValueError("Tenant erasure step references an unknown dependency.")
|
||||
remaining = {
|
||||
step.step_id: set(step.depends_on)
|
||||
for step in self.steps
|
||||
}
|
||||
resolved: set[str] = set()
|
||||
while remaining:
|
||||
ready = sorted(
|
||||
step_id
|
||||
for step_id, dependencies in remaining.items()
|
||||
if dependencies.issubset(resolved)
|
||||
)
|
||||
if not ready:
|
||||
raise ValueError("Tenant erasure step dependencies contain a cycle.")
|
||||
resolved.update(ready)
|
||||
for step_id in ready:
|
||||
remaining.pop(step_id)
|
||||
|
||||
@property
|
||||
def allowed(self) -> bool:
|
||||
return self.complete and not self.blockers
|
||||
|
||||
def to_dict(self) -> dict[str, object]:
|
||||
return {
|
||||
"module_id": self.module_id,
|
||||
"complete": self.complete,
|
||||
"allowed": self.allowed,
|
||||
"provider_revision": self.provider_revision,
|
||||
"resources": [item.to_dict() for item in self.resources],
|
||||
"steps": [item.to_dict() for item in self.steps],
|
||||
"blockers": list(self.blockers),
|
||||
"warnings": list(self.warnings),
|
||||
}
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class TenantErasureStepResult:
|
||||
state: TenantErasureResultState
|
||||
summary: str
|
||||
receipt_ref: str | None = None
|
||||
metrics: Mapping[str, int] = field(default_factory=dict)
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
if self.state not in _RESULT_STATES:
|
||||
raise ValueError("Tenant erasure result state is invalid.")
|
||||
_text(self.summary, "result summary", maximum=1000)
|
||||
if self.receipt_ref is not None:
|
||||
_text(self.receipt_ref, "receipt reference", maximum=500)
|
||||
_metrics(self.metrics)
|
||||
|
||||
def to_dict(self) -> dict[str, object]:
|
||||
return {
|
||||
"state": self.state,
|
||||
"summary": self.summary,
|
||||
"receipt_ref": self.receipt_ref,
|
||||
"metrics": dict(sorted(_metrics(self.metrics).items())),
|
||||
}
|
||||
|
||||
|
||||
@runtime_checkable
|
||||
class TenantErasureProvider(Protocol):
|
||||
module_id: str
|
||||
|
||||
def preview_tenant_erasure(
|
||||
self,
|
||||
session: object,
|
||||
tenant_id: str,
|
||||
) -> TenantErasurePreview:
|
||||
...
|
||||
|
||||
def execute_tenant_erasure_step(
|
||||
self,
|
||||
session: object,
|
||||
tenant_id: str,
|
||||
step_id: str,
|
||||
idempotency_key: str,
|
||||
) -> TenantErasureStepResult:
|
||||
...
|
||||
|
||||
def reconcile_tenant_erasure_step(
|
||||
self,
|
||||
session: object,
|
||||
tenant_id: str,
|
||||
step_id: str,
|
||||
idempotency_key: str,
|
||||
) -> TenantErasureStepResult:
|
||||
...
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class TenantErasureInventory:
|
||||
tenant_id: str
|
||||
generated_at: datetime
|
||||
complete: bool
|
||||
modules: tuple[TenantErasurePreview, ...]
|
||||
|
||||
@property
|
||||
def allowed(self) -> bool:
|
||||
return self.complete and all(item.allowed for item in self.modules)
|
||||
|
||||
def to_dict(self) -> dict[str, object]:
|
||||
generated_at = self.generated_at
|
||||
if generated_at.tzinfo is None:
|
||||
generated_at = generated_at.replace(tzinfo=UTC)
|
||||
return {
|
||||
"schema_version": 1,
|
||||
"tenant_id": self.tenant_id,
|
||||
"generated_at": generated_at.astimezone(UTC).isoformat(),
|
||||
"complete": self.complete,
|
||||
"allowed": self.allowed,
|
||||
"modules": [item.to_dict() for item in self.modules],
|
||||
}
|
||||
|
||||
|
||||
def tenant_erasure_providers(registry: object) -> dict[str, TenantErasureProvider]:
|
||||
capability_names = getattr(registry, "capability_names", None)
|
||||
capability = getattr(registry, "capability", None)
|
||||
if not callable(capability_names) or not callable(capability):
|
||||
raise ValueError("Tenant erasure requires a module registry.")
|
||||
providers: dict[str, TenantErasureProvider] = {}
|
||||
for capability_name in sorted(capability_names()):
|
||||
if not capability_name.startswith(TENANT_ERASURE_PROVIDER_CAPABILITY_PREFIX):
|
||||
continue
|
||||
expected_module_id = capability_name.removeprefix(
|
||||
TENANT_ERASURE_PROVIDER_CAPABILITY_PREFIX
|
||||
)
|
||||
provider = capability(capability_name)
|
||||
if not isinstance(provider, TenantErasureProvider):
|
||||
raise TypeError(
|
||||
f"Tenant erasure provider {expected_module_id or 'unknown'} is invalid."
|
||||
)
|
||||
module_id = _text(provider.module_id, "provider module id", maximum=120)
|
||||
if module_id != expected_module_id or module_id in providers:
|
||||
raise ValueError("Tenant erasure provider identity is invalid.")
|
||||
providers[module_id] = provider
|
||||
return providers
|
||||
|
||||
|
||||
def collect_tenant_erasure_inventory(
|
||||
registry: object,
|
||||
session: object,
|
||||
tenant_id: str,
|
||||
*,
|
||||
observed_at: datetime | None = None,
|
||||
) -> TenantErasureInventory:
|
||||
normalized_tenant_id = _text(tenant_id, "tenant id", maximum=120)
|
||||
manifests = getattr(registry, "manifests", None)
|
||||
summary_providers = getattr(registry, "tenant_summary_providers", None)
|
||||
if not callable(manifests) or not callable(summary_providers):
|
||||
raise ValueError("Tenant erasure inventory requires a module registry.")
|
||||
provider_by_module = tenant_erasure_providers(registry)
|
||||
summary_by_module = dict(summary_providers())
|
||||
manifest_ids = {
|
||||
str(manifest.id)
|
||||
for manifest in manifests()
|
||||
if getattr(manifest, "id", None)
|
||||
}
|
||||
module_ids = manifest_ids | set(summary_by_module) | set(provider_by_module)
|
||||
previews: list[TenantErasurePreview] = []
|
||||
complete = True
|
||||
for module_id in sorted(module_ids):
|
||||
provider = provider_by_module.get(module_id)
|
||||
if provider is not None:
|
||||
try:
|
||||
preview = provider.preview_tenant_erasure(session, normalized_tenant_id)
|
||||
if not isinstance(preview, TenantErasurePreview):
|
||||
raise TypeError("provider returned an invalid preview")
|
||||
if preview.module_id != module_id:
|
||||
raise ValueError("provider returned another module's preview")
|
||||
except Exception as exc:
|
||||
complete = False
|
||||
preview = TenantErasurePreview(
|
||||
module_id=module_id,
|
||||
complete=False,
|
||||
blockers=(
|
||||
f"{type(exc).__name__}: provider preview could not be completed",
|
||||
),
|
||||
)
|
||||
previews.append(preview)
|
||||
complete = complete and preview.complete
|
||||
continue
|
||||
summary_provider = summary_by_module.get(module_id)
|
||||
if summary_provider is None:
|
||||
previews.append(
|
||||
TenantErasurePreview(
|
||||
module_id=module_id,
|
||||
complete=True,
|
||||
warnings=(
|
||||
"Module declares no tenant-owned summary or erasure provider; no tenant persistence is in scope.",
|
||||
),
|
||||
provider_revision="manifest-no-tenant-data",
|
||||
)
|
||||
)
|
||||
continue
|
||||
try:
|
||||
raw_counts = summary_provider(session, normalized_tenant_id)
|
||||
counts = _metrics({str(key): int(value) for key, value in raw_counts.items()})
|
||||
resources = tuple(
|
||||
TenantErasureResource(
|
||||
resource_type=resource_type,
|
||||
count=count,
|
||||
disposition="unavailable" if count else "erase",
|
||||
summary=(
|
||||
"Tenant-owned data requires a module erasure provider."
|
||||
if count
|
||||
else "The module reported no tenant-owned records."
|
||||
),
|
||||
)
|
||||
for resource_type, count in sorted(counts.items())
|
||||
)
|
||||
blockers = (
|
||||
("Tenant-owned data exists but the module has no erasure provider.",)
|
||||
if any(counts.values())
|
||||
else ()
|
||||
)
|
||||
preview = TenantErasurePreview(
|
||||
module_id=module_id,
|
||||
complete=True,
|
||||
resources=resources,
|
||||
blockers=blockers,
|
||||
provider_revision="tenant-summary-fallback",
|
||||
)
|
||||
except Exception as exc:
|
||||
complete = False
|
||||
preview = TenantErasurePreview(
|
||||
module_id=module_id,
|
||||
complete=False,
|
||||
blockers=(
|
||||
f"{type(exc).__name__}: tenant summary could not be completed",
|
||||
),
|
||||
provider_revision="tenant-summary-fallback",
|
||||
)
|
||||
previews.append(preview)
|
||||
timestamp = observed_at or datetime.now(UTC)
|
||||
if timestamp.tzinfo is None:
|
||||
timestamp = timestamp.replace(tzinfo=UTC)
|
||||
return TenantErasureInventory(
|
||||
tenant_id=normalized_tenant_id,
|
||||
generated_at=timestamp,
|
||||
complete=complete,
|
||||
modules=tuple(previews),
|
||||
)
|
||||
|
||||
|
||||
__all__ = [
|
||||
"TENANT_ERASURE_PROVIDER_CAPABILITY_PREFIX",
|
||||
"TenantErasureInventory",
|
||||
"TenantErasurePreview",
|
||||
"TenantErasureProvider",
|
||||
"TenantErasureResource",
|
||||
"TenantErasureStep",
|
||||
"TenantErasureStepResult",
|
||||
"collect_tenant_erasure_inventory",
|
||||
"tenant_erasure_providers",
|
||||
]
|
||||
@@ -27,6 +27,7 @@ LEGACY_TO_MODULE_SCOPES: dict[str, str] = {
|
||||
"system:tenants:create": "access:tenant:create",
|
||||
"system:tenants:update": "access:tenant:update",
|
||||
"system:tenants:suspend": "access:tenant:suspend",
|
||||
"system:tenants:erase": "access:tenant:erase",
|
||||
"system:accounts:read": "access:account:read",
|
||||
"system:accounts:create": "access:account:create",
|
||||
"system:accounts:update": "access:account:update",
|
||||
|
||||
@@ -78,6 +78,7 @@ SYSTEM_PERMISSIONS: tuple[PermissionDefinition, ...] = (
|
||||
PermissionDefinition("system:tenants:create", "Create tenants", "Create new tenant spaces.", "System administration", "system"),
|
||||
PermissionDefinition("system:tenants:update", "Update tenants", "Edit tenant metadata and governance overrides.", "System administration", "system"),
|
||||
PermissionDefinition("system:tenants:suspend", "Suspend tenants", "Activate or suspend tenant spaces while preserving evidence.", "System administration", "system"),
|
||||
PermissionDefinition("system:tenants:erase", "Erase tenants", "Preview, approve, execute, and reconcile governed destructive tenant erasure.", "System administration", "system"),
|
||||
PermissionDefinition("system:accounts:read", "View accounts", "List global login accounts and memberships.", "System administration", "system"),
|
||||
PermissionDefinition("system:accounts:create", "Create accounts", "Create global login accounts.", "System administration", "system"),
|
||||
PermissionDefinition("system:accounts:update", "Update accounts", "Edit global account metadata.", "System administration", "system"),
|
||||
|
||||
@@ -200,6 +200,9 @@ def _nav_item_payload(
|
||||
"visible": scoped.visible,
|
||||
"locked": scoped.locked,
|
||||
"lock_source": scoped.lock_source,
|
||||
"section": scoped.section.as_dict() if scoped.section else None,
|
||||
"custom_layout": scoped.custom_layout,
|
||||
"layout_source": scoped.layout_source,
|
||||
}
|
||||
for scope in ("module", "system", "tenant")
|
||||
if (scoped := navigation.get(scope, {}).get(navigation_id)) is not None
|
||||
|
||||
@@ -164,6 +164,9 @@ class Settings(BaseSettings):
|
||||
ge=60,
|
||||
alias="FILE_ARCHIVE_PREVIEW_TTL_SECONDS",
|
||||
)
|
||||
file_archive_work_root: str | None = Field(default=None, alias="FILE_ARCHIVE_WORK_ROOT")
|
||||
file_archive_staged_max_bytes: int = Field(default=2 * 1024 ** 3, ge=1, alias="FILE_ARCHIVE_STAGED_MAX_BYTES")
|
||||
file_archive_staged_per_actor: int = Field(default=4, ge=1, le=64, alias="FILE_ARCHIVE_STAGED_PER_ACTOR")
|
||||
|
||||
auth_session_cookie_name: str = Field(default="govoplan_session", alias="AUTH_SESSION_COOKIE_NAME")
|
||||
auth_csrf_cookie_name: str = Field(default="govoplan_csrf", alias="AUTH_CSRF_COOKIE_NAME")
|
||||
|
||||
@@ -5271,6 +5271,36 @@ class ApiSmokeTests(unittest.TestCase):
|
||||
visible_key = next(item for item in visible_revoked_delta.json()["api_keys"] if item["id"] == key_id)
|
||||
self.assertIsNotNone(visible_key["revoked_at"])
|
||||
|
||||
def test_navigation_separator_layout_survives_system_tenant_and_personal_saves(self) -> None:
|
||||
headers, _ = self._login()
|
||||
system = self.client.get("/api/v1/admin/system/settings", headers=headers).json()
|
||||
layout = {"contract_version": "1", "order": ["files.navigation.files", "separator:mail", "mail.navigation.mail"], "hidden": [], "locked": ["mail.navigation.mail"], "separators": [{"id": "separator:mail", "label": "Nachrichten"}]}
|
||||
saved = self.client.patch("/api/v1/admin/system/settings", headers=headers, json={
|
||||
**{key: system[key] for key in ("default_locale", "allow_tenant_custom_groups", "allow_tenant_custom_roles", "allow_tenant_api_keys")}, "navigation": layout,
|
||||
})
|
||||
self.assertEqual(200, saved.status_code, saved.text)
|
||||
self.assertEqual(layout, saved.json()["navigation"])
|
||||
tenant = self.client.get("/api/v1/admin/tenant/settings", headers=headers).json()
|
||||
tenant_layout = {**layout, "locked": []}
|
||||
saved = self.client.patch("/api/v1/admin/tenant/settings", headers=headers, json={"default_locale": tenant["default_locale"], "navigation": tenant_layout})
|
||||
self.assertEqual(200, saved.status_code, saved.text)
|
||||
self.assertEqual(tenant_layout, saved.json()["navigation"])
|
||||
personal_layout = {**tenant_layout, "order": ["mail.navigation.mail", "separator:personal", "files.navigation.files"], "hidden": ["mail.navigation.mail"], "separators": [{"id": "separator:personal", "label": "Meine Arbeit"}]}
|
||||
saved = self.client.patch("/api/v1/auth/profile", headers=headers, json={"ui_preferences": {"navigation": personal_layout}})
|
||||
self.assertEqual(200, saved.status_code, saved.text)
|
||||
self.assertEqual(personal_layout, saved.json()["user"]["ui_preferences"]["navigation"])
|
||||
loaded = self.client.get("/api/v1/auth/profile", headers=headers)
|
||||
self.assertEqual(personal_layout, loaded.json()["user"]["ui_preferences"]["navigation"])
|
||||
from govoplan_core.core.navigation import navigation_preferences_from_mapping, resolve_navigation_preferences
|
||||
# HTTP persistence is checked above; scope projection stays independently deterministic.
|
||||
resolved = resolve_navigation_preferences(("files.navigation.files", "mail.navigation.mail"), system=navigation_preferences_from_mapping(layout), tenant=navigation_preferences_from_mapping(tenant_layout), user=navigation_preferences_from_mapping(personal_layout))
|
||||
self.assertTrue(resolved["mail.navigation.mail"].visible)
|
||||
self.assertTrue(resolved["mail.navigation.mail"].locked)
|
||||
self.assertEqual("Meine Arbeit", resolved["files.navigation.files"].section.label)
|
||||
reset = self.client.patch("/api/v1/auth/profile", headers=headers, json={"ui_preferences": {"navigation": None}})
|
||||
self.assertEqual(200, reset.status_code, reset.text)
|
||||
self.assertIsNone(reset.json()["user"]["ui_preferences"]["navigation"])
|
||||
|
||||
def test_settings_deltas_track_sections_and_system_language_dependency(self) -> None:
|
||||
headers, _ = self._login()
|
||||
|
||||
@@ -6860,6 +6890,7 @@ class ApiSmokeTests(unittest.TestCase):
|
||||
"order": ["files.navigation.files", "mail.navigation.mail"],
|
||||
"hidden": ["mail.navigation.mail"],
|
||||
"locked": [],
|
||||
"separators": None,
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
@@ -2,6 +2,7 @@ from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
from datetime import UTC, datetime
|
||||
from pathlib import Path
|
||||
import tempfile
|
||||
import unittest
|
||||
@@ -9,12 +10,53 @@ from unittest.mock import patch
|
||||
|
||||
from govoplan_core.core.infrastructure_capabilities import (
|
||||
InfrastructureCapabilityReceiptError,
|
||||
InfrastructureDependency,
|
||||
collect_infrastructure_dependency_inventory,
|
||||
deployment_capability_status,
|
||||
infrastructure_capability_receipt_from_mapping,
|
||||
load_infrastructure_capability_receipt,
|
||||
)
|
||||
|
||||
|
||||
class _InventoryProvider:
|
||||
module_id = "mail"
|
||||
capability_ids = ("mail.smtp",)
|
||||
|
||||
def infrastructure_dependencies(self) -> tuple[InfrastructureDependency, ...]:
|
||||
return (
|
||||
InfrastructureDependency(
|
||||
capability_id="mail.smtp",
|
||||
module_id="mail",
|
||||
dependency_type="smtp_endpoint",
|
||||
dependency_ref="mail-server:server-1",
|
||||
state="active",
|
||||
scope="system",
|
||||
summary="One active SMTP endpoint uses the deployment relay.",
|
||||
metrics={"credential_binding_count": 1},
|
||||
required_action="Rebind or retire the endpoint before removal.",
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
class _FailingInventoryProvider:
|
||||
module_id = "files"
|
||||
capability_ids = ("files.storage",)
|
||||
|
||||
def infrastructure_dependencies(self) -> tuple[InfrastructureDependency, ...]:
|
||||
raise RuntimeError("database URL must not escape")
|
||||
|
||||
|
||||
class _Registry:
|
||||
def __init__(self, providers: dict[str, object]) -> None:
|
||||
self.providers = providers
|
||||
|
||||
def capability_names(self) -> tuple[str, ...]:
|
||||
return tuple(self.providers)
|
||||
|
||||
def capability(self, name: str) -> object | None:
|
||||
return self.providers.get(name)
|
||||
|
||||
|
||||
def _receipt_payload() -> dict[str, object]:
|
||||
return {
|
||||
"schema_version": 1,
|
||||
@@ -48,6 +90,40 @@ def _receipt_payload() -> dict[str, object]:
|
||||
|
||||
|
||||
class InfrastructureCapabilityReceiptTests(unittest.TestCase):
|
||||
def test_collects_non_secret_provider_dependency_inventory(self) -> None:
|
||||
inventory = collect_infrastructure_dependency_inventory(
|
||||
_Registry(
|
||||
{
|
||||
"infrastructure.dependency_inventory.mail": _InventoryProvider(),
|
||||
"unrelated.capability": object(),
|
||||
}
|
||||
),
|
||||
installation_id="govoplan-test",
|
||||
observed_at=datetime(2026, 8, 24, 12, 0, tzinfo=UTC),
|
||||
)
|
||||
|
||||
self.assertTrue(inventory.complete)
|
||||
self.assertEqual(("mail.smtp",), inventory.inspected_capability_ids)
|
||||
self.assertEqual("mail-server:server-1", inventory.dependencies[0].dependency_ref)
|
||||
self.assertEqual("2026-08-24T12:00:00+00:00", inventory.generated_at)
|
||||
self.assertNotIn("database URL", json.dumps(inventory.to_dict()))
|
||||
|
||||
def test_provider_failure_makes_inventory_incomplete_without_leaking_error(self) -> None:
|
||||
inventory = collect_infrastructure_dependency_inventory(
|
||||
_Registry(
|
||||
{
|
||||
"infrastructure.dependency_inventory.files": (
|
||||
_FailingInventoryProvider()
|
||||
)
|
||||
}
|
||||
),
|
||||
installation_id="govoplan-test",
|
||||
)
|
||||
|
||||
self.assertFalse(inventory.complete)
|
||||
self.assertEqual("error", inventory.providers[0].state)
|
||||
self.assertNotIn("database URL", str(inventory.providers[0].error))
|
||||
|
||||
def test_parses_typed_capability_and_task_lookup(self) -> None:
|
||||
receipt = infrastructure_capability_receipt_from_mapping(_receipt_payload())
|
||||
|
||||
|
||||
@@ -344,6 +344,18 @@ class ModuleSystemTests(unittest.TestCase):
|
||||
self.assertTrue(scopes_grant_compatible(["access:membership:read"], "admin:users:read"))
|
||||
self.assertTrue(scopes_grant_compatible(["admin:users:read"], "access:membership:read"))
|
||||
self.assertTrue(scopes_grant_compatible(["access:tenant:read"], "system:tenants:read"))
|
||||
self.assertTrue(
|
||||
scopes_grant_compatible(
|
||||
["access:tenant:erase"],
|
||||
"system:tenants:erase",
|
||||
)
|
||||
)
|
||||
self.assertFalse(
|
||||
scopes_grant_compatible(
|
||||
["system:tenants:write"],
|
||||
"system:tenants:erase",
|
||||
)
|
||||
)
|
||||
self.assertTrue(scopes_grant_compatible(["system:*"], "access:tenant:read"))
|
||||
self.assertTrue(
|
||||
scopes_grant_compatible(
|
||||
@@ -1015,8 +1027,10 @@ finally:
|
||||
json={"mode": "destroy", "reason": "not supported"},
|
||||
)
|
||||
self.assertEqual(409, destructive.status_code, destructive.text)
|
||||
issue_codes = {item["code"] for item in destructive.json()["detail"]["plan"]["issues"]}
|
||||
self.assertIn("tenant_data_present", issue_codes)
|
||||
self.assertIn(
|
||||
"Direct destructive deletion is disabled",
|
||||
destructive.json()["detail"]["message"],
|
||||
)
|
||||
|
||||
with database.session() as session:
|
||||
empty_tenant = Tenant(
|
||||
@@ -1029,15 +1043,43 @@ finally:
|
||||
session.commit()
|
||||
empty_tenant_id = empty_tenant.id
|
||||
|
||||
destroyed = client.request(
|
||||
"DELETE",
|
||||
f"/api/v1/admin/tenants/{empty_tenant_id}",
|
||||
erasure_policy = client.patch(
|
||||
"/api/v1/admin/tenant-erasure-policy",
|
||||
headers=headers,
|
||||
json={"mode": "destroy", "reason": "empty tenant cleanup"},
|
||||
json={
|
||||
"production_profile": False,
|
||||
"required_approvals": 1,
|
||||
"preview_ttl_seconds": 900,
|
||||
"recent_authentication_seconds": 900,
|
||||
},
|
||||
)
|
||||
self.assertEqual(200, destroyed.status_code, destroyed.text)
|
||||
self.assertEqual("destroy", destroyed.json()["plan"]["action"])
|
||||
self.assertTrue(destroyed.json()["plan"]["destructive_supported"])
|
||||
self.assertEqual(200, erasure_policy.status_code, erasure_policy.text)
|
||||
erasure_preview = client.post(
|
||||
f"/api/v1/admin/tenants/{empty_tenant_id}/erasure-operations",
|
||||
headers=headers,
|
||||
json={
|
||||
"idempotency_key": f"empty-destroy-{name}",
|
||||
"reason": "empty tenant cleanup",
|
||||
},
|
||||
)
|
||||
self.assertEqual(201, erasure_preview.status_code, erasure_preview.text)
|
||||
self.assertTrue(erasure_preview.json()["preview"]["allowed"])
|
||||
operation_id = erasure_preview.json()["id"]
|
||||
approved_erasure = client.post(
|
||||
f"/api/v1/admin/tenants/{empty_tenant_id}/erasure-operations/{operation_id}/approve",
|
||||
headers=headers,
|
||||
json={"confirmation": f"empty-destroy-{name}"},
|
||||
)
|
||||
self.assertEqual(200, approved_erasure.status_code, approved_erasure.text)
|
||||
self.assertEqual("ready", approved_erasure.json()["state"])
|
||||
executed_erasure = client.post(
|
||||
f"/api/v1/admin/tenants/{empty_tenant_id}/erasure-operations/{operation_id}/execute",
|
||||
headers=headers,
|
||||
json={"confirmation": f"empty-destroy-{name}"},
|
||||
)
|
||||
self.assertEqual(200, executed_erasure.status_code, executed_erasure.text)
|
||||
self.assertEqual("completed", executed_erasure.json()["state"])
|
||||
self.assertIsNone(executed_erasure.json()["reason"])
|
||||
|
||||
retired = client.request(
|
||||
"DELETE",
|
||||
|
||||
@@ -4,6 +4,7 @@ import unittest
|
||||
|
||||
from govoplan_core.core.navigation import (
|
||||
NavigationPreferences,
|
||||
NavigationSeparator,
|
||||
navigation_preferences_from_settings,
|
||||
resolve_navigation_preferences,
|
||||
update_navigation_preferences,
|
||||
@@ -11,6 +12,46 @@ from govoplan_core.core.navigation import (
|
||||
|
||||
|
||||
class NavigationPreferenceTests(unittest.TestCase):
|
||||
def test_separator_order_and_labels_round_trip_across_scopes(self) -> None:
|
||||
separator = NavigationSeparator("separator:work", "Arbeit")
|
||||
preferences = NavigationPreferences(order=("dashboard", separator.id, "files", "mail"), separators=(separator,))
|
||||
stored = navigation_preferences_from_settings(update_navigation_preferences({}, preferences))
|
||||
self.assertEqual(preferences, stored)
|
||||
resolved = resolve_navigation_preferences(("dashboard", "files", "mail"), system=stored)
|
||||
self.assertIsNone(resolved["dashboard"].section)
|
||||
self.assertEqual(separator, resolved["files"].section)
|
||||
self.assertEqual(separator, resolved["mail"].section)
|
||||
self.assertTrue(resolved["dashboard"].custom_layout)
|
||||
self.assertEqual("system", resolved["files"].layout_source)
|
||||
self.assertNotIn(separator.id, resolved) # Never an authorized destination.
|
||||
|
||||
def test_personal_separator_override_and_flat_reset_preserve_locks(self) -> None:
|
||||
system = NavigationPreferences(order=("separator:system", "files", "mail"), separators=(NavigationSeparator("separator:system", "System"),), locked=("files",))
|
||||
user = NavigationPreferences(order=("mail", "separator:user", "files"), hidden=("files",), separators=(NavigationSeparator("separator:user", "Persönlich"),))
|
||||
resolved = resolve_navigation_preferences(("files", "mail"), system=system, user=user)
|
||||
self.assertEqual("user", resolved["files"].layout_source)
|
||||
self.assertEqual("Persönlich", resolved["files"].section.label)
|
||||
self.assertTrue(resolved["files"].visible)
|
||||
self.assertIsNone(resolved["mail"].section)
|
||||
flat = resolve_navigation_preferences(("files", "mail"), system=system, user=NavigationPreferences(separators=()))
|
||||
self.assertTrue(flat["files"].custom_layout)
|
||||
self.assertIsNone(flat["files"].section)
|
||||
self.assertTrue(flat["files"].locked)
|
||||
|
||||
def test_legacy_preferences_inherit_separator_layout(self) -> None:
|
||||
separator = NavigationSeparator("separator:work", "Work")
|
||||
resolved = resolve_navigation_preferences(("files", "mail"), system=NavigationPreferences(order=(separator.id, "files", "mail"), separators=(separator,)), user=NavigationPreferences(hidden=("mail",)))
|
||||
self.assertEqual(separator, resolved["files"].section)
|
||||
self.assertEqual("system", resolved["files"].layout_source)
|
||||
self.assertFalse(resolved["mail"].visible)
|
||||
|
||||
def test_separator_schema_rejects_unsafe_and_unknown_fields(self) -> None:
|
||||
from pydantic import ValidationError
|
||||
from govoplan_core.api.v1.schemas import NavigationPreferencesPayload
|
||||
for separator in ({"id": "files", "label": "Invalid"}, {"id": "separator:ok", "label": "bad\nlabel"}, {"id": "separator:ok", "route": "/admin"}):
|
||||
with self.subTest(separator=separator), self.assertRaises(ValidationError):
|
||||
NavigationPreferencesPayload.model_validate({"separators": [separator]})
|
||||
|
||||
def test_user_order_overrides_tenant_and_system_order(self) -> None:
|
||||
resolved = resolve_navigation_preferences(
|
||||
("dashboard", "files", "mail", "campaign"),
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import tempfile
|
||||
import unittest
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from pathlib import Path
|
||||
|
||||
from alembic import command
|
||||
from sqlalchemy import create_engine, inspect, select, text
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from govoplan_core.core.ownership import OwnershipTransfer
|
||||
from govoplan_core.db.migrations import alembic_config
|
||||
|
||||
|
||||
class OwnershipHistoryMigrationTests(unittest.TestCase):
|
||||
def test_upgrade_existing_databases_without_rewriting_ownership(self) -> None:
|
||||
for track in ("release", "dev"):
|
||||
for legacy in (True, False):
|
||||
with self.subTest(track=track, legacy=legacy):
|
||||
self._verify_upgrade(track, legacy=legacy)
|
||||
|
||||
def _verify_upgrade(self, track: str, *, legacy: bool) -> None:
|
||||
with tempfile.TemporaryDirectory(prefix="govoplan-ownership-upgrade-") as directory:
|
||||
url = f"sqlite:///{Path(directory) / 'upgrade.db'}"
|
||||
config = alembic_config(database_url=url, enabled_modules=(), migration_track=track)
|
||||
command.upgrade(config, "b47e6f809a13")
|
||||
engine = create_engine(url)
|
||||
try:
|
||||
evidence = [{"sequence": 1, "action": "requested"}]
|
||||
now = datetime.now(timezone.utc)
|
||||
with Session(engine) as session:
|
||||
session.add(OwnershipTransfer(
|
||||
id="transfer-1", tenant_id="tenant-1", resource_module="campaigns",
|
||||
resource_type="campaign", resource_id="campaign-1", kind="owner_initiated",
|
||||
status="awaiting_target_acceptance", current_owner_type="user",
|
||||
current_owner_id="owner-1", target_owner_type="user", target_owner_id="owner-2",
|
||||
initiated_by_type="user", initiated_by_id="owner-1", reason="Existing request",
|
||||
approvals=[{"actor_id": "owner-1"}], decisions=evidence,
|
||||
idempotency_key="request-1", canonical_request_hash="a" * 64,
|
||||
expires_at=now + timedelta(days=7), revision=3, metadata_={"retained": True},
|
||||
created_at=now, updated_at=now,
|
||||
))
|
||||
session.commit()
|
||||
with engine.begin() as connection:
|
||||
if legacy:
|
||||
connection.execute(text("ALTER TABLE core_ownership_transfers DROP COLUMN decisions"))
|
||||
before = dict(connection.execute(text(
|
||||
"SELECT * FROM core_ownership_transfers WHERE id = 'transfer-1'"
|
||||
)).mappings().one())
|
||||
command.upgrade(config, "c58a2d7e9f10")
|
||||
command.upgrade(config, "c58a2d7e9f10")
|
||||
with engine.connect() as connection:
|
||||
columns = {column["name"]: column for column in inspect(connection).get_columns(
|
||||
"core_ownership_transfers"
|
||||
)}
|
||||
self.assertFalse(columns["decisions"]["nullable"])
|
||||
after = dict(connection.execute(text(
|
||||
"SELECT * FROM core_ownership_transfers WHERE id = 'transfer-1'"
|
||||
)).mappings().one())
|
||||
self.assertEqual({key: after[key] for key in before}, before)
|
||||
with Session(engine) as session:
|
||||
transfer = session.scalars(select(OwnershipTransfer)).one()
|
||||
self.assertEqual(transfer.decisions, [] if legacy else evidence)
|
||||
self.assertEqual(transfer.approvals, [{"actor_id": "owner-1"}])
|
||||
transfer.decisions = [*transfer.decisions, {"action": "accepted"}]
|
||||
session.commit()
|
||||
command.downgrade(config, "b47e6f809a13")
|
||||
command.upgrade(config, "c58a2d7e9f10")
|
||||
with Session(engine) as session:
|
||||
self.assertEqual(session.get(OwnershipTransfer, "transfer-1").decisions[-1], {
|
||||
"action": "accepted"
|
||||
})
|
||||
finally:
|
||||
engine.dispose()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,180 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import UTC, datetime
|
||||
from types import SimpleNamespace
|
||||
|
||||
import pytest
|
||||
|
||||
from govoplan_core.core.tenant_erasure import (
|
||||
TENANT_ERASURE_PROVIDER_CAPABILITY_PREFIX,
|
||||
TenantErasurePreview,
|
||||
TenantErasureResource,
|
||||
TenantErasureStep,
|
||||
TenantErasureStepResult,
|
||||
collect_tenant_erasure_inventory,
|
||||
tenant_erasure_providers,
|
||||
)
|
||||
|
||||
|
||||
class _Provider:
|
||||
module_id = "files"
|
||||
|
||||
def preview_tenant_erasure(self, session, tenant_id: str) -> TenantErasurePreview:
|
||||
del session
|
||||
assert tenant_id == "tenant-1"
|
||||
return TenantErasurePreview(
|
||||
module_id=self.module_id,
|
||||
complete=True,
|
||||
resources=(
|
||||
TenantErasureResource(
|
||||
resource_type="file_blobs",
|
||||
count=2,
|
||||
disposition="erase",
|
||||
summary="Two tenant-owned file blobs will be erased.",
|
||||
),
|
||||
),
|
||||
steps=(
|
||||
TenantErasureStep(
|
||||
step_id="erase-blobs",
|
||||
kind="erase",
|
||||
summary="Erase tenant-owned file blobs.",
|
||||
destructive=True,
|
||||
irreversible=True,
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
def execute_tenant_erasure_step(
|
||||
self, session, tenant_id: str, step_id: str, idempotency_key: str
|
||||
) -> TenantErasureStepResult:
|
||||
del session, tenant_id, step_id, idempotency_key
|
||||
return TenantErasureStepResult(
|
||||
state="completed",
|
||||
summary="Tenant file blobs erased.",
|
||||
metrics={"deleted": 2},
|
||||
)
|
||||
|
||||
def reconcile_tenant_erasure_step(
|
||||
self, session, tenant_id: str, step_id: str, idempotency_key: str
|
||||
) -> TenantErasureStepResult:
|
||||
return self.execute_tenant_erasure_step(
|
||||
session, tenant_id, step_id, idempotency_key
|
||||
)
|
||||
|
||||
|
||||
class _Registry:
|
||||
def __init__(self, *, provider: object | None = None, counts: dict[str, int] | None = None):
|
||||
self._provider = provider
|
||||
self._counts = counts
|
||||
|
||||
def manifests(self):
|
||||
return (
|
||||
SimpleNamespace(id="core"),
|
||||
SimpleNamespace(id="files"),
|
||||
SimpleNamespace(id="wiki"),
|
||||
)
|
||||
|
||||
def capability_names(self):
|
||||
if self._provider is None:
|
||||
return ()
|
||||
return (f"{TENANT_ERASURE_PROVIDER_CAPABILITY_PREFIX}files",)
|
||||
|
||||
def capability(self, name: str):
|
||||
assert name.endswith("files")
|
||||
return self._provider
|
||||
|
||||
def tenant_summary_providers(self):
|
||||
if self._counts is None:
|
||||
return {}
|
||||
return {"files": lambda _session, _tenant_id: self._counts}
|
||||
|
||||
|
||||
def test_contract_rejects_unsafe_irreversible_step() -> None:
|
||||
with pytest.raises(ValueError, match="must be destructive"):
|
||||
TenantErasureStep(
|
||||
step_id="unsafe",
|
||||
kind="erase",
|
||||
summary="Invalid step.",
|
||||
destructive=False,
|
||||
irreversible=True,
|
||||
)
|
||||
|
||||
|
||||
def test_contract_rejects_cyclic_step_dependencies() -> None:
|
||||
with pytest.raises(ValueError, match="contain a cycle"):
|
||||
TenantErasurePreview(
|
||||
module_id="files",
|
||||
complete=True,
|
||||
steps=(
|
||||
TenantErasureStep(
|
||||
step_id="first",
|
||||
kind="erase",
|
||||
summary="First.",
|
||||
destructive=True,
|
||||
irreversible=True,
|
||||
depends_on=("second",),
|
||||
),
|
||||
TenantErasureStep(
|
||||
step_id="second",
|
||||
kind="verify",
|
||||
summary="Second.",
|
||||
destructive=False,
|
||||
irreversible=False,
|
||||
depends_on=("first",),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def test_contract_requires_action_or_blocker_for_tenant_data() -> None:
|
||||
resource = TenantErasureResource(
|
||||
resource_type="files",
|
||||
count=1,
|
||||
disposition="erase",
|
||||
summary="One file exists.",
|
||||
)
|
||||
with pytest.raises(ValueError, match="steps or an explicit blocker"):
|
||||
TenantErasurePreview(
|
||||
module_id="files",
|
||||
complete=True,
|
||||
resources=(resource,),
|
||||
)
|
||||
|
||||
|
||||
def test_inventory_collects_provider_and_marks_non_data_modules() -> None:
|
||||
inventory = collect_tenant_erasure_inventory(
|
||||
_Registry(provider=_Provider()),
|
||||
object(),
|
||||
"tenant-1",
|
||||
observed_at=datetime(2026, 8, 24, 12, 0, tzinfo=UTC),
|
||||
)
|
||||
|
||||
assert inventory.complete
|
||||
assert inventory.allowed
|
||||
assert [item.module_id for item in inventory.modules] == ["core", "files", "wiki"]
|
||||
assert inventory.modules[1].steps[0].irreversible
|
||||
assert inventory.to_dict()["generated_at"] == "2026-08-24T12:00:00+00:00"
|
||||
|
||||
|
||||
def test_summary_fallback_blocks_when_data_exists() -> None:
|
||||
inventory = collect_tenant_erasure_inventory(
|
||||
_Registry(counts={"file_blobs": 3}),
|
||||
object(),
|
||||
"tenant-1",
|
||||
)
|
||||
|
||||
files = next(item for item in inventory.modules if item.module_id == "files")
|
||||
assert inventory.complete
|
||||
assert not inventory.allowed
|
||||
assert files.resources[0].disposition == "unavailable"
|
||||
assert files.blockers == (
|
||||
"Tenant-owned data exists but the module has no erasure provider.",
|
||||
)
|
||||
|
||||
|
||||
def test_provider_identity_must_match_capability_suffix() -> None:
|
||||
provider = _Provider()
|
||||
provider.module_id = "mail"
|
||||
|
||||
with pytest.raises(ValueError, match="identity"):
|
||||
tenant_erasure_providers(_Registry(provider=provider))
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"initialJs": {
|
||||
"rawBytes": 524288,
|
||||
"gzipBytes": 163840
|
||||
"gzipBytes": 164128
|
||||
},
|
||||
"asyncChunk": {
|
||||
"rawBytes": 393216,
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
import AddressBookPage from "../../../govoplan-addresses/webui/src/features/addressbook/AddressBookPage";
|
||||
import { generatedTranslations } from "../../../govoplan-addresses/webui/src/i18n/generatedTranslations";
|
||||
import { PlatformLanguageProvider } from "../src/i18n/LanguageContext";
|
||||
import type { AuthInfo } from "../src/types";
|
||||
import "../../../govoplan-addresses/webui/src/styles/addresses.css";
|
||||
|
||||
const settings = { apiBaseUrl: "", apiKey: "", accessToken: "" };
|
||||
|
||||
export default function AddressExplorerScenario() {
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
const auth: AuthInfo = {
|
||||
user: { id: "address-fixture-user", account_id: "address-fixture-account", email: "address-fixture@example.test" },
|
||||
tenant: { id: "address-fixture-tenant", name: "Address fixture", slug: "address-fixture" },
|
||||
scopes: params.has("read-only") ? ["addresses:contact:read"] : [
|
||||
"addresses:contact:read", "addresses:contact:write", "addresses:contact:delete",
|
||||
"addresses:address_book:write", "addresses:address_book:delete",
|
||||
"addresses:address_list:write", "addresses:address_list:delete",
|
||||
"addresses:sync:read", "addresses:sync:write", "addresses:governance:read"
|
||||
],
|
||||
roles: [], groups: [], profile_loaded: true, groups_loaded: true, roles_loaded: true
|
||||
};
|
||||
return <PlatformLanguageProvider preferredLanguageCode={params.get("language") ?? "en"} moduleTranslations={[generatedTranslations]}>
|
||||
<AddressBookPage settings={settings} auth={auth} />
|
||||
</PlatformLanguageProvider>;
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
import { useState } from "react";
|
||||
import AttachmentsDataPage from "../../../govoplan-campaign/webui/src/features/campaigns/AttachmentsDataPage";
|
||||
import { generatedTranslations as campaignTranslations } from "../../../govoplan-campaign/webui/src/i18n/generatedTranslations";
|
||||
import ManagedFileChooser from "../../../govoplan-files/webui/src/features/files/components/ManagedFileChooser";
|
||||
import { listFileSpaces } from "../../../govoplan-files/webui/src/api/files";
|
||||
import { generatedTranslations as filesTranslations } from "../../../govoplan-files/webui/src/i18n/generatedTranslations";
|
||||
import { ConcurrencyConflictProvider } from "../src/components/ConcurrencyConflictDialog";
|
||||
import { PlatformLanguageProvider } from "../src/i18n/LanguageContext";
|
||||
import { PlatformModulesProvider } from "../src/platform/ModuleContext";
|
||||
import type { AuthInfo, PlatformWebModule } from "../src/types";
|
||||
import "../../../govoplan-campaign/webui/src/styles/campaign-workspace.css";
|
||||
import "../../../govoplan-files/webui/src/styles/file-manager.css";
|
||||
|
||||
const auth: AuthInfo = { user: { id: "user-1", account_id: "account-1", email: "fixture@example.test" },
|
||||
tenant: { id: "tenant-1", name: "Fixture", slug: "fixture" }, scopes: ["campaigns:write", "files:file:read"],
|
||||
roles: [], groups: [], profile_loaded: true, roles_loaded: true, groups_loaded: true };
|
||||
|
||||
export default function CampaignAttachmentsScenario() {
|
||||
const [available, setAvailable] = useState(true);
|
||||
const modules: PlatformWebModule[] = [
|
||||
{ id: "campaigns", label: "Campaign", version: "fixture" },
|
||||
{ id: "files", label: "Files", version: "fixture", uiCapabilities: available ? {
|
||||
"files.fileExplorer": { ManagedFileChooser, listFileSpaces }
|
||||
} : {} }
|
||||
];
|
||||
return <PlatformModulesProvider modules={modules}>
|
||||
<PlatformLanguageProvider preferredLanguageCode="en" moduleTranslations={[campaignTranslations, filesTranslations]}>
|
||||
<button type="button" onClick={() => setAvailable(value => !value)}>Toggle Files capability</button>
|
||||
<ConcurrencyConflictProvider>
|
||||
<AttachmentsDataPage settings={{ apiBaseUrl: "", apiKey: "", accessToken: "" }} auth={auth} campaignId="campaign-attachments" />
|
||||
</ConcurrencyConflictProvider>
|
||||
</PlatformLanguageProvider>
|
||||
</PlatformModulesProvider>;
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
import { useState } from "react";
|
||||
import { apiPostJson } from "../src/api/client";
|
||||
import { PlatformLanguageProvider } from "../src/i18n/LanguageContext";
|
||||
import BulkMessageReviewDialog from "../../../govoplan-campaign/webui/src/features/campaigns/review/BulkMessageReviewDialog";
|
||||
import { generatedTranslations } from "../../../govoplan-campaign/webui/src/i18n/generatedTranslations";
|
||||
import "../../../govoplan-campaign/webui/src/styles/campaign-workspace.css";
|
||||
|
||||
function job(id: string, overrides: Record<string, unknown> = {}) {
|
||||
return { id, build_status: "built", validation_status: "needs_review", recipient_email: `${id}@example.test`, subject: `Frozen ${id}`,
|
||||
review_decision: { eligible: true, category_key: "same-attachment-condition", reason_required: true, issue_codes: ["attachment_match_empty"] }, ...overrides };
|
||||
}
|
||||
|
||||
export default function CampaignBulkReviewScenario() {
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
const [open, setOpen] = useState(false);
|
||||
const [buildToken, setBuildToken] = useState("build-one");
|
||||
const rows = [...Array.from({ length: params.has("large") ? 205 : 3 }, (_, index) => job(`job-${String(index).padStart(3, "0")}`)),
|
||||
job("reviewed", { reviewed: true }), job("blocked", { validation_status: "blocked", review_decision: { eligible: false } }),
|
||||
job("expected-exclusion", { validation_status: "excluded", review_decision: { eligible: false } }),
|
||||
job("allowed-zero", { validation_status: "warning", review_decision: { eligible: false } }),
|
||||
job("other-category", { review_decision: { eligible: true, category_key: "other-condition", reason_required: false, issue_codes: ["address_warning"] } })];
|
||||
return <PlatformLanguageProvider preferredLanguageCode={params.get("language") ?? "en"} moduleTranslations={[generatedTranslations]}>
|
||||
<main><button onClick={() => setOpen(true)}>Open grouped review</button>
|
||||
<button data-testid="replace-build" onClick={() => setBuildToken("build-two")}>Replace build</button>
|
||||
{open && <BulkMessageReviewDialog rows={rows} buildToken={buildToken} disabled={params.has("read-only")}
|
||||
onClose={() => setOpen(false)} onAccept={async (selection) => {
|
||||
await apiPostJson({ apiBaseUrl: "", apiKey: "", accessToken: "" }, "/api/v1/conformance/review-state", selection);
|
||||
}} />}
|
||||
</main>
|
||||
</PlatformLanguageProvider>;
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import { PlatformLanguageProvider } from "../src/i18n/LanguageContext";
|
||||
import CampaignDeliveryPolicyPanel from "../../../govoplan-campaign/webui/src/features/admin/CampaignDeliveryPolicyPanel";
|
||||
|
||||
export default function CampaignDeliveryPolicyScenario() {
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
return <PlatformLanguageProvider preferredLanguageCode={params.get("language") ?? "en"}>
|
||||
<CampaignDeliveryPolicyPanel settings={{ apiBaseUrl: "", accessToken: "", apiKey: "" }}
|
||||
scope={params.get("scope") === "tenant" ? "tenant" : "system"} canWrite={!params.has("readonly")} />
|
||||
</PlatformLanguageProvider>;
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
import ReviewSendPage from "../../../govoplan-campaign/webui/src/features/campaigns/ReviewSendPage";
|
||||
import { generatedTranslations } from "../../../govoplan-campaign/webui/src/i18n/generatedTranslations";
|
||||
import { PlatformLanguageProvider } from "../src/i18n/LanguageContext";
|
||||
import { PlatformModulesProvider } from "../src/platform/ModuleContext";
|
||||
import type { AuthInfo, PlatformWebModule } from "../src/types";
|
||||
import "../../../govoplan-campaign/webui/src/styles/campaign-workspace.css";
|
||||
const modules: PlatformWebModule[] = [{ id: "campaigns", label: "Campaign", version: "fixture" }];
|
||||
const auth: AuthInfo = { user: { id: "sender", account_id: "sender-account", email: "sender@example.test" },
|
||||
tenant: { id: "tenant", name: "Fixture", slug: "fixture" }, scopes: ["campaigns:campaign:read", "campaigns:recipient:read", "campaigns:campaign:review",
|
||||
"campaigns:campaign:validate", "campaigns:campaign:build", "campaigns:campaign:send", "campaigns:campaign:queue", "campaigns:campaign:retry", "campaigns:campaign:control"],
|
||||
roles: [], groups: [], profile_loaded: true, roles_loaded: true, groups_loaded: true };
|
||||
export default function CampaignDeliveryProgressScenario() {
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
return <PlatformModulesProvider modules={modules}>
|
||||
<PlatformLanguageProvider preferredLanguageCode={params.get("language") ?? "en"} moduleTranslations={[generatedTranslations]}>
|
||||
<ReviewSendPage settings={{ apiBaseUrl: "", apiKey: "", accessToken: "" }} auth={auth} campaignId="delivery-campaign" />
|
||||
</PlatformLanguageProvider>
|
||||
</PlatformModulesProvider>;
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
// Real optional-module settings surface with test-owned HTTP responses only.
|
||||
import MailSettingsPage from "../../../govoplan-campaign/webui/src/features/campaigns/MailSettingsPage";
|
||||
import { generatedTranslations } from "../../../govoplan-campaign/webui/src/i18n/generatedTranslations";
|
||||
import { ConcurrencyConflictProvider } from "../src/components/ConcurrencyConflictDialog";
|
||||
import { PlatformLanguageProvider } from "../src/i18n/LanguageContext";
|
||||
import { PlatformModulesProvider } from "../src/platform/ModuleContext";
|
||||
import type { PlatformWebModule } from "../src/types";
|
||||
import "../../../govoplan-campaign/webui/src/styles/campaign-workspace.css";
|
||||
|
||||
const settings = { apiBaseUrl: "", accessToken: "", apiKey: "" };
|
||||
const modules: PlatformWebModule[] = [
|
||||
{ id: "campaigns", label: "Campaign", version: "fixture" },
|
||||
{ id: "mail", label: "Mail", version: "fixture" }
|
||||
];
|
||||
|
||||
export default function CampaignMailSettingsScenario() {
|
||||
const language = new URLSearchParams(window.location.search).get("language") ?? "en";
|
||||
return <PlatformModulesProvider modules={modules}>
|
||||
<PlatformLanguageProvider preferredLanguageCode={language} moduleTranslations={[generatedTranslations]}>
|
||||
<ConcurrencyConflictProvider>
|
||||
<MailSettingsPage settings={settings} campaignId="campaign-mail" />
|
||||
</ConcurrencyConflictProvider>
|
||||
</PlatformLanguageProvider>
|
||||
</PlatformModulesProvider>;
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
import { useCallback, useState } from "react";
|
||||
import { ConcurrencyConflictProvider } from "../src/components/ConcurrencyConflictDialog";
|
||||
import { PlatformLanguageProvider } from "../src/i18n/LanguageContext";
|
||||
import { useCampaignDraftEditor } from "../../../govoplan-campaign/webui/src/features/campaigns/hooks/useCampaignDraftEditor";
|
||||
import { getCampaignVersion, type CampaignVersionDetail } from "../../../govoplan-campaign/webui/src/api/campaigns";
|
||||
import {
|
||||
HeaderAddressEditorDialog, RecipientAddressEditorDialog, entryWithAddressValues, getAddressColumn,
|
||||
type HeaderAddressValues
|
||||
} from "../../../govoplan-campaign/webui/src/features/campaigns/recipients/RecipientAddressEditor";
|
||||
import { generatedTranslations } from "../../../govoplan-campaign/webui/src/i18n/generatedTranslations";
|
||||
import "../../../govoplan-campaign/webui/src/styles/campaign-workspace.css";
|
||||
|
||||
const settings = { apiBaseUrl: "", accessToken: "", apiKey: "" };
|
||||
const initialAddresses = [
|
||||
{ name: "Alpha", email: "alpha@example.test" },
|
||||
{ name: "Beta", email: "beta@example.test" },
|
||||
{ name: "Zulu", email: "zulu@example.test" }
|
||||
];
|
||||
const initialVersion: CampaignVersionDetail = {
|
||||
id: "order-version", campaign_id: "order-campaign", version_number: 1, edit_revision: 1, strong_etag: '"order-version:1"',
|
||||
current_flow: "manual", current_step: "recipients", workflow_state: "editing", is_complete: false,
|
||||
raw_json: {
|
||||
campaign: { name: "Recipient ordering" }, server: {},
|
||||
recipients: { allow_individual_to: true, to: initialAddresses },
|
||||
entries: { defaults: {}, inline: [{ id: "entry-1", name: "Alpha", email: "alpha@example.test", to: initialAddresses }] }
|
||||
}, editor_state: {}, updated_at: "2026-09-07T10:00:00Z"
|
||||
};
|
||||
|
||||
export default function CampaignRecipientOrderScenario() {
|
||||
return <PlatformLanguageProvider preferredLanguageCode={new URLSearchParams(window.location.search).get("language") ?? "en"} moduleTranslations={[generatedTranslations]}>
|
||||
<ConcurrencyConflictProvider><RecipientOrderEditor /></ConcurrencyConflictProvider>
|
||||
</PlatformLanguageProvider>;
|
||||
}
|
||||
|
||||
function RecipientOrderEditor() {
|
||||
const [version, setVersion] = useState(initialVersion);
|
||||
const [error, setError] = useState("");
|
||||
const [dialog, setDialog] = useState<"entry" | "header" | null>(null);
|
||||
const reload = useCallback(async () => setVersion(await getCampaignVersion(settings, "order-campaign", "order-version")), []);
|
||||
const editor = useCampaignDraftEditor({ settings, campaignId: "order-campaign", version, locked: false,
|
||||
reload, setError, currentStep: "recipients", unsavedTitle: "Unsaved recipient order", unsavedMessage: "Save or discard the campaign draft?" });
|
||||
const entries = editor.displayDraft.entries as { defaults: Record<string, unknown>; inline: Record<string, unknown>[] };
|
||||
const recipients = editor.displayDraft.recipients as Record<string, unknown>;
|
||||
const entry = entries?.inline?.[0] ?? {};
|
||||
return <main>
|
||||
<button onClick={() => setDialog("entry")}>Edit individual addresses</button>
|
||||
<button onClick={() => setDialog("header")}>Edit global addresses</button>
|
||||
<button disabled={!editor.dirty || editor.saving} onClick={() => void editor.saveDraft()}>Save campaign</button>
|
||||
<output data-testid="recipient-order">{JSON.stringify(entry.to)}</output>
|
||||
<output data-testid="global-order">{JSON.stringify(recipients?.to)}</output>
|
||||
<output data-testid="primary-address">{String(entry.email ?? "")}</output>
|
||||
<output data-testid="recipient-dirty">{String(editor.dirty)}</output>
|
||||
<output data-testid="recipient-error">{editor.localError || error}</output>
|
||||
{dialog === "entry" && <RecipientAddressEditorDialog
|
||||
entry={entry} index={0} locked={false} recipientsSection={recipients} entryDefaults={entries.defaults ?? {}}
|
||||
onSave={(values, merges) => {
|
||||
// Same owning helper as RecipientDataPage; no test copy of ordering or
|
||||
// legacy primary-address/merge-field normalization.
|
||||
editor.patch(["entries", "inline"], [entryWithAddressValues(entry, values, merges)]);
|
||||
setDialog(null);
|
||||
}} onClose={() => setDialog(null)} />}
|
||||
{dialog === "header" && <HeaderAddressEditorDialog title="Global To addresses"
|
||||
columns={[getAddressColumn("to")]} values={{ to: recipients.to } as HeaderAddressValues} locked={false}
|
||||
onSave={(values) => { editor.patch(["recipients", "to"], values.to); setDialog(null); }} onClose={() => setDialog(null)} />}
|
||||
</main>;
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import CampaignReportPage from "../../../govoplan-campaign/webui/src/features/campaigns/CampaignReportPage";
|
||||
import { useState } from "react";
|
||||
import { generatedTranslations } from "../../../govoplan-campaign/webui/src/i18n/generatedTranslations";
|
||||
import { PlatformLanguageProvider } from "../src/i18n/LanguageContext";
|
||||
import { PlatformModulesProvider } from "../src/platform/ModuleContext";
|
||||
import type { AuthInfo, PlatformWebModule } from "../src/types";
|
||||
import "../../../govoplan-campaign/webui/src/styles/campaign-workspace.css";
|
||||
const modules: PlatformWebModule[] = [{ id: "campaigns", label: "Campaign", version: "fixture" }];
|
||||
export default function CampaignReportScenario() {
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
const [account, setAccount] = useState("reporter");
|
||||
const read = ["campaigns:campaign:read", "campaigns:recipient:read", "campaigns:report:read"];
|
||||
const auth: AuthInfo = { user: { id: account, account_id: `${account}-account`, email: "reporter@example.test" },
|
||||
tenant: { id: "tenant", name: "Fixture", slug: "fixture" }, scopes: params.has("read-only") ? read :
|
||||
[...read, "campaigns:campaign:send", "campaigns:campaign:retry", "campaigns:campaign:queue", "campaigns:campaign:reconcile"],
|
||||
roles: [], groups: [], profile_loaded: true, roles_loaded: true, groups_loaded: true };
|
||||
return <PlatformModulesProvider modules={modules}>
|
||||
<PlatformLanguageProvider preferredLanguageCode={params.get("language") ?? "en"} moduleTranslations={[generatedTranslations]}>
|
||||
{params.has("switch-account") && <button type="button" onClick={() => setAccount("another-reporter")}>Switch fixture account</button>}
|
||||
<CampaignReportPage settings={{ apiBaseUrl: "", apiKey: "", accessToken: "" }} auth={auth} campaignId="report-campaign" />
|
||||
</PlatformLanguageProvider>
|
||||
</PlatformModulesProvider>;
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
import ValidationDetails from "../../../govoplan-campaign/webui/src/features/campaigns/review/ValidationDetails";
|
||||
import RepeatedFilesDetails from "../../../govoplan-campaign/webui/src/features/campaigns/review/RepeatedFilesDetails";
|
||||
import { generatedTranslations } from "../../../govoplan-campaign/webui/src/i18n/generatedTranslations";
|
||||
import { PlatformLanguageProvider } from "../src/i18n/LanguageContext";
|
||||
import { useState } from "react";
|
||||
|
||||
const issues = Array.from({ length: 12 }, (_, index) => [
|
||||
{ code: "missing_required_attachment", severity: "warning", path: `/entries/recipient-${index + 1}/attachments/0`, message: `Missing attachment for recipient ${index + 1}.` },
|
||||
{ code: "missing_attachment_coverage", severity: "info", path: `/entries/recipient-${index + 1}`, message: `Policy excludes recipient ${index + 1} without attachments.` }
|
||||
]).flat();
|
||||
const findings = Array.from({ length: 12 }, (_, index) => ({ file_name: `repeated-file-${index + 1}.pdf`, file_fingerprint: `file-${index}`, use_count: 3, disposition: "allowed" }));
|
||||
|
||||
export default function CampaignReviewDetailsScenario() {
|
||||
const [count, setCount] = useState(12);
|
||||
return <PlatformLanguageProvider preferredLanguageCode={new URLSearchParams(window.location.search).get("language") ?? "en"} moduleTranslations={[generatedTranslations]}>
|
||||
<button onClick={() => setCount(2)}>Reduce fixture details</button>
|
||||
<section aria-label="Validation details fixture"><ValidationDetails issues={issues.slice(0, count * 2)} /></section>
|
||||
<section aria-label="Repeated files fixture"><RepeatedFilesDetails findings={findings.slice(0, count)} /></section>
|
||||
</PlatformLanguageProvider>;
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
import ReviewSendPage from "../../../govoplan-campaign/webui/src/features/campaigns/ReviewSendPage";
|
||||
import { useSearchParams } from "react-router";
|
||||
import { generatedTranslations } from "../../../govoplan-campaign/webui/src/i18n/generatedTranslations";
|
||||
import { PlatformLanguageProvider } from "../src/i18n/LanguageContext";
|
||||
import { PlatformModulesProvider } from "../src/platform/ModuleContext";
|
||||
import type { AuthInfo, PlatformWebModule } from "../src/types";
|
||||
import "../../../govoplan-campaign/webui/src/styles/campaign-workspace.css";
|
||||
const auth: AuthInfo = { user: { id: "reviewer", account_id: "reviewer-account", email: "reviewer@example.test" },
|
||||
tenant: { id: "tenant", name: "Fixture", slug: "fixture" }, scopes: ["campaigns:campaign:read", "campaigns:campaign:review", "campaigns:campaign:build"],
|
||||
roles: [], groups: [], profile_loaded: true, roles_loaded: true, groups_loaded: true };
|
||||
const modules: PlatformWebModule[] = [{ id: "campaigns", label: "Campaign", version: "fixture" }];
|
||||
export default function CampaignReviewScenario() {
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
const [, setSearchParams] = useSearchParams();
|
||||
const fixtureAuth = params.has("read-only") ? { ...auth, scopes: ["campaigns:campaign:read"] } : auth;
|
||||
return <PlatformModulesProvider modules={modules}>
|
||||
<PlatformLanguageProvider preferredLanguageCode={params.get("language") ?? "en"} moduleTranslations={[generatedTranslations]}>
|
||||
{params.has("race") && <button onClick={() => setSearchParams((current) => {
|
||||
const next = new URLSearchParams(current); next.set("version", "replacement-version"); return next;
|
||||
})}>Switch fixture version</button>}
|
||||
<ReviewSendPage settings={{ apiBaseUrl: "", apiKey: "", accessToken: "" }} auth={fixtureAuth} campaignId="review-campaign" />
|
||||
</PlatformLanguageProvider>
|
||||
</PlatformModulesProvider>;
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
import { useCallback, useState } from "react";
|
||||
import { Link } from "react-router";
|
||||
import { ConcurrencyConflictProvider } from "../src/components/ConcurrencyConflictDialog";
|
||||
import { PlatformLanguageProvider } from "../src/i18n/LanguageContext";
|
||||
import { useCampaignDraftEditor } from "../../../govoplan-campaign/webui/src/features/campaigns/hooks/useCampaignDraftEditor";
|
||||
import { getCampaignVersion, type CampaignVersionDetail } from "../../../govoplan-campaign/webui/src/api/campaigns";
|
||||
import { generatedTranslations } from "../../../govoplan-campaign/webui/src/i18n/generatedTranslations";
|
||||
|
||||
const settings = { apiBaseUrl: "", accessToken: "", apiKey: "" };
|
||||
const initialVersion: CampaignVersionDetail = {
|
||||
id: "version-a", campaign_id: "campaign-a", version_number: 1, edit_revision: 1, strong_etag: '"version-a:1"',
|
||||
current_flow: "manual", current_step: "template", workflow_state: "editing", is_complete: false,
|
||||
raw_json: { campaign: { name: "Original" }, template: { subject: "Original subject", text: "" }, server: {} },
|
||||
editor_state: {}, updated_at: "2026-09-07T10:00:00Z"
|
||||
};
|
||||
|
||||
export default function CampaignSavingScenario() {
|
||||
return <PlatformLanguageProvider preferredLanguageCode="en" moduleTranslations={[generatedTranslations]}>
|
||||
<ConcurrencyConflictProvider><SavingEditor /></ConcurrencyConflictProvider>
|
||||
</PlatformLanguageProvider>;
|
||||
}
|
||||
|
||||
function SavingEditor() {
|
||||
const [version, setVersion] = useState(initialVersion);
|
||||
const [error, setError] = useState("");
|
||||
const [result, setResult] = useState("");
|
||||
const reload = useCallback(async () => setVersion(await getCampaignVersion(settings, "campaign-a", "version-a")), []);
|
||||
const editor = useCampaignDraftEditor({ settings, campaignId: "campaign-a", version, locked: false,
|
||||
reload, setError, currentStep: "template", unsavedTitle: "Unsaved fixture changes", unsavedMessage: "Save or discard the draft?" });
|
||||
const subject = (editor.displayDraft.template as Record<string, unknown>)?.subject ?? "";
|
||||
return <main>
|
||||
<label>Subject<input aria-label="Subject" value={String(subject)} onChange={(event) => editor.patch(["template", "subject"], event.target.value)} /></label>
|
||||
<button disabled={editor.saving} onClick={() => void editor.saveDraft().then((saved) => setResult(String(saved)))}>Save draft</button>
|
||||
<button disabled={editor.saving} onClick={() => { void editor.saveDraft(); void editor.saveDraft(); }}>Request save twice</button>
|
||||
<button disabled={editor.saving} onClick={() => void editor.discardDraft()}>Discard draft</button>
|
||||
<button onClick={() => setVersion({ ...initialVersion, raw_json: { ...initialVersion.raw_json, template: { subject: "Background refresh" } } })}>Background refresh</button>
|
||||
<Link to="/?campaign-saving&elsewhere">Other route</Link>
|
||||
<output data-testid="save-busy">{String(editor.saving)}</output>
|
||||
<output data-testid="save-dirty">{String(editor.dirty)}</output>
|
||||
<output data-testid="save-status">{editor.saveState}</output>
|
||||
<output data-testid="save-error">{editor.localError || error}</output>
|
||||
<output data-testid="save-result">{result}</output>
|
||||
</main>;
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
// Exercise the owning Campaign hook, with network responses controlled by tests.
|
||||
import { useMemo, useState } from "react";
|
||||
import { useSearchParams } from "react-router";
|
||||
import { useCampaignWorkspaceData } from "../../../govoplan-campaign/webui/src/features/campaigns/hooks/useCampaignWorkspaceData";
|
||||
|
||||
export default function CampaignWorkspaceScenario() {
|
||||
const [campaignId, setCampaignId] = useState("campaign-a");
|
||||
const [accessToken, setAccessToken] = useState("token-a");
|
||||
const [, setSearchParams] = useSearchParams();
|
||||
const settings = useMemo(() => ({ apiBaseUrl: "", apiKey: "", accessToken }), [accessToken]);
|
||||
const { data, loading, error, reload } = useCampaignWorkspaceData(settings, campaignId, { includeVersions: true });
|
||||
return <main>
|
||||
<button onClick={() => void reload()}>Reload workspace</button>
|
||||
<button onClick={() => void reload({ force: true })}>Force reload workspace</button>
|
||||
<button onClick={() => setCampaignId("campaign-b")}>Switch campaign</button>
|
||||
<button onClick={() => setAccessToken("token-b")}>Switch identity</button>
|
||||
<button onClick={() => setSearchParams((current) => {
|
||||
const next = new URLSearchParams(current);
|
||||
next.set("version", "selected-version");
|
||||
return next;
|
||||
})}>Select version</button>
|
||||
<output data-testid="workspace-campaign">{data.campaign?.id ?? "none"}</output>
|
||||
<output data-testid="workspace-version">{data.currentVersion?.id ?? "none"}</output>
|
||||
<output data-testid="workspace-revision">{data.currentVersion?.edit_revision ?? 0}</output>
|
||||
<output data-testid="workspace-loading">{String(loading)}</output>
|
||||
<output data-testid="workspace-error">{error}</output>
|
||||
</main>;
|
||||
}
|
||||
@@ -1,6 +1,37 @@
|
||||
import { useMemo, useState } from "react";
|
||||
import { FileText, GitBranch, Inbox, Search, ShieldCheck } from "lucide-react";
|
||||
import { CalendarDays, FileText, Folder, GitBranch, Inbox, ListChecks, Mail, Search, ShieldCheck } from "lucide-react";
|
||||
import { useLocation } from "react-router";
|
||||
import DialogLayoutScenario from "./DialogLayoutScenario";
|
||||
import DataGridLayoutScenario from "./DataGridLayoutScenario";
|
||||
import NavigationLayoutScenario from "./NavigationLayoutScenario";
|
||||
import ManagedArchiveScenario from "./ManagedArchiveScenario";
|
||||
import FilesToolbarScenario from "./FilesToolbarScenario";
|
||||
import CredentialReferencesScenario from "./CredentialReferencesScenario";
|
||||
import FormControlLayoutScenario from "./FormControlLayoutScenario";
|
||||
import CampaignWorkspaceScenario from "./CampaignWorkspaceScenario";
|
||||
import CampaignReportScenario from "./CampaignReportScenario";
|
||||
import ModuleLayoutScenario from "./ModuleLayoutScenario";
|
||||
import HelpCenterScenario from "./HelpCenterScenario";
|
||||
import NotificationFilterScenario from "./NotificationFilterScenario";
|
||||
import MultiSelectFilterScenario from "./MultiSelectFilterScenario";
|
||||
import SearchFiltersScenario from "./SearchFiltersScenario";
|
||||
import AddressExplorerScenario from "./AddressExplorerScenario";
|
||||
import MailFolderExplorerScenario from "./MailFolderExplorerScenario";
|
||||
import MailToolbarScenario from "./MailToolbarScenario";
|
||||
import CampaignDeliveryProgressScenario from "./CampaignDeliveryProgressScenario";
|
||||
import CampaignSavingScenario from "./CampaignSavingScenario";
|
||||
import CampaignMailSettingsScenario from "./CampaignMailSettingsScenario";
|
||||
import CampaignAttachmentsScenario from "./CampaignAttachmentsScenario";
|
||||
import CampaignRecipientOrderScenario from "./CampaignRecipientOrderScenario";
|
||||
import CampaignReviewScenario from "./CampaignReviewScenario";
|
||||
import CampaignBulkReviewScenario from "./CampaignBulkReviewScenario";
|
||||
import CampaignReviewDetailsScenario from "./CampaignReviewDetailsScenario";
|
||||
import CampaignDeliveryPolicyScenario from "./CampaignDeliveryPolicyScenario";
|
||||
import MailCredentialPolicyScenario from "./MailCredentialPolicyScenario";
|
||||
import type { NavigationPreferenceScope } from "../src/components/navigationPreferenceLayout";
|
||||
import FormInstancePage from "../../../govoplan-forms-runtime/webui/src/features/forms/FormInstancePage";
|
||||
import FormsRuntimePage from "../../../govoplan-forms-runtime/webui/src/features/forms/FormsRuntimePage";
|
||||
import PublicFormPage from "../../../govoplan-forms-runtime/webui/src/features/forms/PublicFormPage";
|
||||
import QuickAccessRail from "../../../govoplan-quick-access/webui/src/components/QuickAccessRail";
|
||||
import ActionToolbar from "../src/components/ActionToolbar";
|
||||
import Button from "../src/components/Button";
|
||||
@@ -25,14 +56,26 @@ import StatePanel from "../src/components/StatePanel";
|
||||
import WorkspaceFrame from "../src/components/WorkspaceFrame";
|
||||
import WorkspaceLayout from "../src/components/WorkspaceLayout";
|
||||
import WorkspaceActionBar from "../src/components/WorkspaceActionBar";
|
||||
import WysiwygEditor from "../src/components/WysiwygEditor";
|
||||
import BreadcrumbBar from "../src/layout/BreadcrumbBar";
|
||||
import HelpMenu from "../src/layout/HelpMenu";
|
||||
import IconRail from "../src/layout/IconRail";
|
||||
import { useGuardedNavigate } from "../src/components/UnsavedChangesGuard";
|
||||
import {
|
||||
createQuickAccessLaunchContext,
|
||||
quickAccessLaunchState
|
||||
} from "../src/platform/launchContext";
|
||||
import type { ApiSettings, AuthInfo, EffectiveViewProjection, QuickAccessToolMetadata } from "../src/types";
|
||||
import { projectProductNavigation } from "../src/platform/productSurfaces";
|
||||
import type {
|
||||
ApiSettings,
|
||||
AuthInfo,
|
||||
EffectiveViewProjection,
|
||||
PlatformNavItem,
|
||||
PlatformWebModule,
|
||||
ProductAreaContribution,
|
||||
ProductSurfaceContribution,
|
||||
QuickAccessToolMetadata
|
||||
} from "../src/types";
|
||||
|
||||
export default function ConformanceApp() {
|
||||
const location = useLocation();
|
||||
@@ -40,6 +83,63 @@ export default function ConformanceApp() {
|
||||
const [editorDirty, setEditorDirty] = useState(true);
|
||||
const [metricDrilldown, setMetricDrilldown] = useState("");
|
||||
|
||||
if (new URLSearchParams(location.search).has("credential-references")) return <CredentialReferencesScenario />;
|
||||
if (new URLSearchParams(location.search).has("files-toolbar")) return <FilesToolbarScenario />;
|
||||
if (new URLSearchParams(location.search).has("form-control-layout")) return <FormControlLayoutScenario />;
|
||||
if (new URLSearchParams(location.search).has("campaign-workspace")) return <CampaignWorkspaceScenario />;
|
||||
if (new URLSearchParams(location.search).has("campaign-report")) return <CampaignReportScenario />;
|
||||
if (new URLSearchParams(location.search).has("module-layouts")) return <ModuleLayoutScenario />;
|
||||
if (new URLSearchParams(location.search).has("help-center")) return <HelpCenterScenario />;
|
||||
if (new URLSearchParams(location.search).has("notification-filter")) return <NotificationFilterScenario />;
|
||||
if (new URLSearchParams(location.search).has("multi-select-filter")) return <MultiSelectFilterScenario />;
|
||||
if (new URLSearchParams(location.search).has("search-filters")) return <SearchFiltersScenario />;
|
||||
if (new URLSearchParams(location.search).has("address-explorer")) return <AddressExplorerScenario />;
|
||||
if (new URLSearchParams(location.search).has("mail-folder-explorer")) return <MailFolderExplorerScenario />;
|
||||
if (new URLSearchParams(location.search).has("mail-toolbar")) return <MailToolbarScenario />;
|
||||
if (new URLSearchParams(location.search).has("campaign-delivery-progress")) return <CampaignDeliveryProgressScenario />;
|
||||
if (new URLSearchParams(location.search).has("campaign-saving")) return <CampaignSavingScenario />;
|
||||
if (new URLSearchParams(location.search).has("campaign-mail-settings")) return <CampaignMailSettingsScenario />;
|
||||
if (new URLSearchParams(location.search).has("campaign-attachments")) return <CampaignAttachmentsScenario />;
|
||||
if (new URLSearchParams(location.search).has("campaign-recipient-order")) return <CampaignRecipientOrderScenario />;
|
||||
if (new URLSearchParams(location.search).has("campaign-review")) return <CampaignReviewScenario />;
|
||||
if (new URLSearchParams(location.search).has("campaign-bulk-review")) return <CampaignBulkReviewScenario />;
|
||||
if (new URLSearchParams(location.search).has("campaign-review-details")) return <CampaignReviewDetailsScenario />;
|
||||
if (new URLSearchParams(location.search).has("campaign-delivery-policy")) return <CampaignDeliveryPolicyScenario />;
|
||||
if (new URLSearchParams(location.search).has("mail-credential-policy")) return <MailCredentialPolicyScenario />;
|
||||
|
||||
if (new URLSearchParams(location.search).has("data-grid-layout")) return <DataGridLayoutScenario />;
|
||||
if (new URLSearchParams(location.search).has("managed-archive")) {
|
||||
const params = new URLSearchParams(location.search);
|
||||
return <ManagedArchiveScenario language={params.get("language") ?? "en"} downloadAllowed={!params.has("no-download")} />;
|
||||
}
|
||||
if (new URLSearchParams(location.search).has("navigation-layout")) {
|
||||
const params = new URLSearchParams(location.search);
|
||||
return <NavigationLayoutScenario scope={(params.get("scope") ?? "user") as NavigationPreferenceScope} language={params.get("language") ?? "en"} disabled={params.has("disabled")} />;
|
||||
}
|
||||
|
||||
if (new URLSearchParams(location.search).has("dialog-layout")) {
|
||||
const params = new URLSearchParams(location.search);
|
||||
return <DialogLayoutScenario templates={params.get("fixture") === "templates"} language={params.get("language") ?? "de"} />;
|
||||
}
|
||||
|
||||
if (new URLSearchParams(location.search).has("wysiwyg-lifecycle")) {
|
||||
return <WysiwygLifecycleScenario source={new URLSearchParams(location.search).get("mode") === "source"} />;
|
||||
}
|
||||
|
||||
if (new URLSearchParams(location.search).has("product-navigation")) {
|
||||
return <ProductNavigationScenario />;
|
||||
}
|
||||
|
||||
if (location.pathname.startsWith("/forms/public/")) {
|
||||
return <PublicFormPage settings={CONFORMANCE_SETTINGS} auth={FORMS_RUNTIME_AUTH} />;
|
||||
}
|
||||
if (location.pathname === "/forms-runtime") {
|
||||
return <FormsRuntimePage settings={CONFORMANCE_SETTINGS} auth={FORMS_RUNTIME_AUTH} />;
|
||||
}
|
||||
if (location.pathname.startsWith("/forms-runtime/")) {
|
||||
return <FormInstancePage settings={CONFORMANCE_SETTINGS} auth={FORMS_RUNTIME_AUTH} />;
|
||||
}
|
||||
|
||||
return (
|
||||
<main className="conformance-root" data-conformance-id="shared-ui-lab">
|
||||
<PageLayout
|
||||
@@ -172,6 +272,68 @@ export default function ConformanceApp() {
|
||||
);
|
||||
}
|
||||
|
||||
function WysiwygLifecycleScenario({ source }: { source: boolean }) {
|
||||
const initialHtml = source
|
||||
? '<table style="width: 100%"><tbody><tr><td>Legacy template</td></tr></tbody></table>'
|
||||
: "<p>Legacy <i>template</i></p>";
|
||||
const [value, setValue] = useState(initialHtml);
|
||||
const [changeCount, setChangeCount] = useState(0);
|
||||
const [disabled, setDisabled] = useState(false);
|
||||
const [mounted, setMounted] = useState(true);
|
||||
|
||||
return <main className="conformance-root">
|
||||
<h1>Rich-text lifecycle fixture</h1>
|
||||
<Button onClick={() => setDisabled((current) => !current)}>Toggle read-only</Button>
|
||||
<Button onClick={() => setMounted((current) => !current)}>Toggle editor mount</Button>
|
||||
<Button onClick={() => setValue(initialHtml.replace("Legacy", "Reloaded"))}>Load another value</Button>
|
||||
<output data-testid="wysiwyg-change-count">{changeCount}</output>
|
||||
<pre data-testid="wysiwyg-controlled-value">{value}</pre>
|
||||
{mounted && <WysiwygEditor
|
||||
value={value}
|
||||
disabled={disabled}
|
||||
ariaLabel="Rich-text fixture content"
|
||||
labels={{ visual: "Visual fixture mode", source: "Source fixture mode" }}
|
||||
onChange={(nextValue) => {
|
||||
setValue(nextValue);
|
||||
setChangeCount((current) => current + 1);
|
||||
}}
|
||||
/>}
|
||||
</main>;
|
||||
}
|
||||
|
||||
function ProductNavigationScenario() {
|
||||
const projection = useMemo(
|
||||
() => projectProductNavigation(
|
||||
PRODUCT_NAV_ITEMS,
|
||||
PRODUCT_NAV_MODULES,
|
||||
PRODUCT_NAV_AUTH
|
||||
),
|
||||
[]
|
||||
);
|
||||
return (
|
||||
<div className="app-shell" data-conformance-id="product-navigation">
|
||||
<IconRail
|
||||
navItems={projection.primaryItems}
|
||||
allToolItems={projection.allToolItems}
|
||||
productAreas={PRODUCT_NAV_AREAS}
|
||||
/>
|
||||
<main className="main-area">
|
||||
<PageLayout
|
||||
archetype="overview"
|
||||
mode="embedded"
|
||||
title="Anwohnerparkausweis bearbeiten"
|
||||
description="Die Navigation beschreibt Arbeit und Ergebnisse; technische Eigentümer bleiben nachvollziehbar erreichbar."
|
||||
>
|
||||
<StatePanel
|
||||
title="Vorgang ist bereit"
|
||||
description="Nutzen Sie Arbeit, Kalender, Nachrichten oder Dateien für den nächsten Schritt."
|
||||
/>
|
||||
</PageLayout>
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function HelpConformanceScenario() {
|
||||
return (
|
||||
<section className="conformance-section" aria-labelledby="help-heading">
|
||||
@@ -290,6 +452,132 @@ const CONFORMANCE_AUTH = {
|
||||
groups_loaded: true
|
||||
} satisfies AuthInfo;
|
||||
|
||||
const PRODUCT_NAV_AUTH = {
|
||||
...CONFORMANCE_AUTH,
|
||||
scopes: [
|
||||
"tasks:item:read",
|
||||
"calendar:event:read",
|
||||
"mail:mailbox:read",
|
||||
"postbox:message:read",
|
||||
"files:file:read"
|
||||
]
|
||||
} satisfies AuthInfo;
|
||||
|
||||
const PRODUCT_NAV_ITEMS: PlatformNavItem[] = [
|
||||
{ to: "/tasks", label: "Tasks", icon: ListChecks, surfaceId: "tasks.nav.tasks", anyOf: ["tasks:item:read"], order: 30 },
|
||||
{ to: "/files", label: "Files", icon: Folder, surfaceId: "files.nav.files", anyOf: ["files:file:read"], order: 40 },
|
||||
{ to: "/mail", label: "Mail", icon: Mail, surfaceId: "mail.nav.mail", anyOf: ["mail:mailbox:read"], order: 50 },
|
||||
{ to: "/postbox", label: "Postbox", icon: Inbox, surfaceId: "postbox.nav.postbox", anyOf: ["postbox:message:read"], order: 51 },
|
||||
{ to: "/calendar", label: "Calendar", icon: CalendarDays, surfaceId: "calendar.nav.calendar", anyOf: ["calendar:event:read"], order: 55 }
|
||||
];
|
||||
|
||||
const PRODUCT_NAV_AREAS: ProductAreaContribution[] = [
|
||||
{ id: "work", moduleId: "tasks", label: "i18n:govoplan-core.product_area.work", iconName: "list-checks", surfaceIds: ["tasks.nav.tasks"], order: 10 },
|
||||
{ id: "records-documents", moduleId: "files", label: "i18n:govoplan-core.product_area.records_documents", iconName: "folder", surfaceIds: ["files.nav.files"], order: 30 },
|
||||
{ id: "communication", moduleId: "mail", label: "i18n:govoplan-core.product_area.communication", iconName: "mail", surfaceIds: ["mail.nav.mail", "postbox.nav.postbox"], order: 40 },
|
||||
{ id: "meetings-decisions", moduleId: "calendar", label: "i18n:govoplan-core.product_area.meetings_decisions", iconName: "calendar", surfaceIds: ["calendar.nav.calendar"], order: 50 }
|
||||
];
|
||||
|
||||
const PRODUCT_NAV_MODULES: PlatformWebModule[] = [
|
||||
productModule("tasks", productSurface({
|
||||
id: "work.items",
|
||||
moduleId: "tasks",
|
||||
label: "i18n:govoplan-core.product_surface.work",
|
||||
description: "i18n:govoplan-core.product_surface.work_description",
|
||||
iconName: "list-checks",
|
||||
entryPath: "/work",
|
||||
routePath: "/tasks",
|
||||
surfaceIds: ["tasks.nav.tasks"],
|
||||
anyOf: ["tasks:item:read"]
|
||||
})),
|
||||
productModule("files", productSurface({
|
||||
id: "records.files",
|
||||
moduleId: "files",
|
||||
label: "i18n:govoplan-core.product_surface.files",
|
||||
description: "i18n:govoplan-core.product_surface.files_description",
|
||||
iconName: "folder",
|
||||
entryPath: "/documents",
|
||||
routePath: "/files",
|
||||
surfaceIds: ["files.nav.files"],
|
||||
anyOf: ["files:file:read"]
|
||||
})),
|
||||
productModule("mail", productSurface({
|
||||
id: "communication.messages",
|
||||
moduleId: "mail",
|
||||
label: "i18n:govoplan-core.product_surface.messages",
|
||||
description: "i18n:govoplan-core.product_surface.messages_description",
|
||||
iconName: "mail",
|
||||
entryPath: "/messages",
|
||||
routePath: "/mail",
|
||||
surfaceIds: ["mail.nav.mail"],
|
||||
anyOf: ["mail:mailbox:read"],
|
||||
aliases: ["/inbox"]
|
||||
})),
|
||||
productModule("postbox", productSurface({
|
||||
id: "communication.messages",
|
||||
moduleId: "postbox",
|
||||
label: "i18n:govoplan-core.product_surface.messages",
|
||||
description: "i18n:govoplan-core.product_surface.messages_description",
|
||||
iconName: "mail",
|
||||
entryPath: "/messages",
|
||||
routePath: "/postbox",
|
||||
surfaceIds: ["postbox.nav.postbox"],
|
||||
anyOf: ["postbox:message:read"],
|
||||
aliases: ["/inbox"],
|
||||
order: 20
|
||||
})),
|
||||
productModule("calendar", productSurface({
|
||||
id: "meetings.calendar",
|
||||
moduleId: "calendar",
|
||||
label: "i18n:govoplan-core.product_surface.calendar",
|
||||
description: "i18n:govoplan-core.product_surface.calendar_description",
|
||||
iconName: "calendar",
|
||||
entryPath: "/agenda",
|
||||
routePath: "/calendar",
|
||||
surfaceIds: ["calendar.nav.calendar"],
|
||||
anyOf: ["calendar:event:read"]
|
||||
}))
|
||||
];
|
||||
|
||||
function productModule(id: string, surface: ProductSurfaceContribution): PlatformWebModule {
|
||||
return { id, label: id, version: "test", productSurfaces: [surface] };
|
||||
}
|
||||
|
||||
function productSurface(
|
||||
partial: Pick<ProductSurfaceContribution,
|
||||
"id" | "moduleId" | "label" | "description" | "iconName" | "entryPath" |
|
||||
"routePath" | "surfaceIds" | "anyOf"> & Partial<ProductSurfaceContribution>
|
||||
): ProductSurfaceContribution {
|
||||
return {
|
||||
contractVersion: "1",
|
||||
presentations: ["task", "reader"],
|
||||
capabilityIds: [],
|
||||
searchSourceIds: [],
|
||||
helpContextIds: [],
|
||||
documentationTopicIds: [],
|
||||
allOf: [],
|
||||
aliases: [],
|
||||
order: 10,
|
||||
unavailable: {
|
||||
reason: "authorization",
|
||||
title: "Not available",
|
||||
description: "The destination is not available for this responsibility.",
|
||||
resolution: "Ask the access administrator to review the assignment."
|
||||
},
|
||||
...partial
|
||||
};
|
||||
}
|
||||
|
||||
const FORMS_RUNTIME_AUTH = {
|
||||
...CONFORMANCE_AUTH,
|
||||
scopes: [
|
||||
"forms_runtime:submission:assist",
|
||||
"forms_runtime:submission:participate",
|
||||
"forms_runtime:workspace:read",
|
||||
"forms_runtime:workspace:write"
|
||||
]
|
||||
} satisfies AuthInfo;
|
||||
|
||||
const CONFORMANCE_SETTINGS: ApiSettings = {
|
||||
apiBaseUrl: "",
|
||||
apiKey: "",
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
// Exercise the real shared credential editor with Mail's optional capability.
|
||||
// API fixtures supply public metadata only; this never loads or saves secrets.
|
||||
import CredentialEnvelopeManager from "../src/components/CredentialEnvelopeManager";
|
||||
import { PlatformModulesProvider } from "../src/platform/ModuleContext";
|
||||
import { mailCredentialReferenceSelectors } from "../../../govoplan-mail/webui/src/features/mail/mailReferenceProviders";
|
||||
import type { ApiSettings, PlatformWebModule } from "../src/types";
|
||||
|
||||
const settings: ApiSettings = { apiBaseUrl: "", apiKey: "", accessToken: "" };
|
||||
const modules: PlatformWebModule[] = [{
|
||||
id: "mail", label: "Mail", version: "1",
|
||||
uiCapabilities: { "core.credentialReferenceSelectors": mailCredentialReferenceSelectors }
|
||||
}];
|
||||
|
||||
export default function CredentialReferencesScenario() {
|
||||
return <PlatformModulesProvider modules={modules}>
|
||||
<CredentialEnvelopeManager settings={settings} scopeType="tenant" canWrite />
|
||||
</PlatformModulesProvider>;
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
import { useMemo, useState } from "react";
|
||||
import { ArrowDown, ArrowUp, Eye, Plus, Trash2 } from "lucide-react";
|
||||
import Button from "../src/components/Button";
|
||||
import DataGrid, { DataGridEmptyAction, type DataGridColumn, type DataGridResizeBehavior } from "../src/components/table/DataGrid";
|
||||
import TableActionGroup from "../src/components/table/TableActionGroup";
|
||||
|
||||
type Row = { id: string; name: string; detail: string };
|
||||
const rows: Row[] = [{ id: "alpha", name: "Alpha", detail: "Long configured field value ".repeat(20) }];
|
||||
|
||||
/** Genuine shared grid: intentionally undersized legacy action preference. */
|
||||
export default function DataGridLayoutScenario() {
|
||||
const mode = new URLSearchParams(window.location.search).get("mode") ?? "cover";
|
||||
const behavior: DataGridResizeBehavior = mode === "free" || mode === "constrained" ? mode : "cover";
|
||||
const composite = mode === "composite";
|
||||
const [width, setWidth] = useState(900);
|
||||
const [mounted, setMounted] = useState(true);
|
||||
const [empty, setEmpty] = useState(false);
|
||||
const [extraAction, setExtraAction] = useState(false);
|
||||
const [clicked, setClicked] = useState("");
|
||||
const columns = useMemo<DataGridColumn<Row>[]>(() => [
|
||||
{ id: "name", header: "Name", width: 260, minWidth: 180, resizable: true, value: (row) => row.name },
|
||||
{ id: "detail", header: "Details", width: 360, minWidth: 220, resizable: true, value: (row) => row.detail },
|
||||
{
|
||||
id: "actions", header: "Actions", width: mode === "oversized" ? 500 : 72,
|
||||
minWidth: mode === "oversized" ? 500 : undefined,
|
||||
columnType: composite ? "actions" : undefined, sticky: behavior === "free" ? undefined : "end",
|
||||
render: (row) => {
|
||||
const group = <TableActionGroup actions={[
|
||||
{ id: "inspect", label: `Inspect ${row.name}`, icon: <Eye />, onClick: () => setClicked(`Inspect ${row.name}`) },
|
||||
{ id: "up", label: "Move up", icon: <ArrowUp />, disabledReason: "Already first", onClick: () => undefined },
|
||||
{ id: "down", label: "Move down", icon: <ArrowDown />, onClick: () => setClicked("Move down") },
|
||||
{ id: "remove", label: "Remove row", icon: <Trash2 />, onClick: () => setClicked("Remove row") },
|
||||
extraAction && { id: "add", label: "Add below", icon: <Plus />, onClick: () => setClicked("Add below") }
|
||||
]} />;
|
||||
return composite ? <div role="group" aria-label="Composite controls" style={{ display: "flex", gap: 4, width: "100%" }}>
|
||||
<Button className="table-action-button" aria-label="Extra control" onClick={() => setClicked("Extra control")}><Plus /></Button>
|
||||
{group}
|
||||
</div> : group;
|
||||
}
|
||||
}
|
||||
], [behavior, composite, extraAction, mode]);
|
||||
return (
|
||||
<main style={{ padding: 16, minWidth: 0 }}>
|
||||
<h1>Data grid layout conformance</h1>
|
||||
<div style={{ display: "flex", gap: 8, flexWrap: "wrap", marginBottom: 16 }}>
|
||||
<Button onClick={() => setWidth(320)}>Narrow grid</Button>
|
||||
<Button onClick={() => setWidth(900)}>Wide grid</Button>
|
||||
<Button onClick={() => setMounted((value) => !value)}>Toggle grid mount</Button>
|
||||
<Button onClick={() => setEmpty((value) => !value)}>Toggle empty rows</Button>
|
||||
<Button onClick={() => setExtraAction((value) => !value)}>Toggle extra action</Button>
|
||||
</div>
|
||||
<output data-testid="clicked-action">{clicked}</output>
|
||||
<div data-testid="grid-container" style={{ display: "grid", gridTemplateColumns: "minmax(0, 1fr)", width, maxWidth: "100%", minWidth: 0 }}>
|
||||
{mounted && <DataGrid
|
||||
id={`layout-conformance-${behavior}`}
|
||||
rows={empty ? [] : rows}
|
||||
columns={columns}
|
||||
getRowKey={(row) => row.id}
|
||||
initialFit={behavior === "free" ? "content" : "container"}
|
||||
resizeBehavior={behavior}
|
||||
emptyAction={<DataGridEmptyAction onAdd={() => setClicked("Add first row")} />}
|
||||
/>}
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
import { useState } from "react";
|
||||
import TemplatesPage from "../../../govoplan-templates/webui/src/features/templates/TemplatesPage";
|
||||
import { generatedTranslations as templateTranslations } from "../../../govoplan-templates/webui/src/i18n/generatedTranslations";
|
||||
import "../../../govoplan-templates/webui/src/styles/templates.css";
|
||||
import Button from "../src/components/Button";
|
||||
import Dialog from "../src/components/Dialog";
|
||||
import { DialogForm, DialogSection } from "../src/components/DialogAnatomy";
|
||||
import { FormGrid } from "../src/components/ContentGrid";
|
||||
import FormField from "../src/components/FormField";
|
||||
import { PlatformLanguageProvider } from "../src/i18n/LanguageContext";
|
||||
import type { AuthInfo } from "../src/types";
|
||||
|
||||
const fixtureAuth: AuthInfo = {
|
||||
user: { id: "fixture-user", account_id: "fixture-account", email: "fixture@example.test" },
|
||||
tenant: { id: "fixture-tenant", name: "Fixture", slug: "fixture" },
|
||||
scopes: ["templates:template:read", "templates:template:write"],
|
||||
roles: [], groups: [], profile_loaded: true, roles_loaded: true, groups_loaded: true
|
||||
};
|
||||
|
||||
export default function DialogLayoutScenario({ templates = false, language = "de" }: { templates?: boolean; language?: string }) {
|
||||
const [open, setOpen] = useState(true);
|
||||
if (templates) return <PlatformLanguageProvider preferredLanguageCode={language} moduleTranslations={[templateTranslations]}>
|
||||
<TemplatesPage settings={{ apiBaseUrl: "", apiKey: "", accessToken: "" }} auth={fixtureAuth} />
|
||||
</PlatformLanguageProvider>;
|
||||
return <main className="conformance-root">
|
||||
<Button onClick={() => setOpen(true)}>Open layout fixture</Button>
|
||||
<Dialog
|
||||
open={open}
|
||||
title={`LangeDialogüberschriftOhneTrennzeichen${"Zusatz".repeat(12)}`}
|
||||
description={`Referenz:${"abcdef0123456789".repeat(12)}`}
|
||||
onClose={() => setOpen(false)}
|
||||
footer={<><Button>Abbrechen</Button><Button variant="primary">Änderungen speichern</Button></>}
|
||||
>
|
||||
<DialogForm onSubmit={(event) => event.preventDefault()}>
|
||||
<DialogSection>
|
||||
<FormGrid columns={2} collapseAt="standard">
|
||||
<FormField label={`Feldname${"Lang".repeat(20)}`}><input defaultValue={"Langer Wert ".repeat(20)} /></FormField>
|
||||
<FormField label="Auswahl"><select defaultValue="long"><option value="long">{"Lange Auswahlliste ".repeat(20)}</option></select></FormField>
|
||||
</FormGrid>
|
||||
</DialogSection>
|
||||
<FormField label="Mehrzeiliger Inhalt"><textarea defaultValue={"Nachweistext ".repeat(60)} rows={5} /></FormField>
|
||||
<div style={{ maxWidth: "100%", minWidth: 0, overflowX: "auto" }} data-testid="dialog-local-scroll">
|
||||
<table style={{ width: 1400 }}><tbody><tr><td>Absichtlich breite Tabelle</td><td>Letzte Tabellenspalte</td></tr></tbody></table>
|
||||
</div>
|
||||
</DialogForm>
|
||||
</Dialog>
|
||||
</main>;
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
// Real owning Files module with fixture-only authentication; tests intercept every API call.
|
||||
import { useLocation } from "react-router";
|
||||
import { useState } from "react";
|
||||
import FilesPage from "../../../govoplan-files/webui/src/features/files/FilesPage";
|
||||
import { generatedTranslations } from "../../../govoplan-files/webui/src/i18n/generatedTranslations";
|
||||
import "../../../govoplan-files/webui/src/styles/file-manager.css";
|
||||
import { PlatformLanguageProvider } from "../src/i18n/LanguageContext";
|
||||
import type { AuthInfo } from "../src/types";
|
||||
|
||||
const fullAuth: AuthInfo = {
|
||||
user: { id: "fixture-user", account_id: "fixture-account", email: "fixture@example.test" },
|
||||
tenant: { id: "fixture-tenant", name: "Fixture", slug: "fixture" },
|
||||
scopes: ["files:file:read", "files:file:download", "files:file:upload", "files:file:organize", "files:file:delete", "files:file:share", "access:role:read"],
|
||||
roles: [], groups: [], profile_loaded: true, roles_loaded: true, groups_loaded: true
|
||||
};
|
||||
const readOnlyAuth = { ...fullAuth, scopes: ["files:file:read"] };
|
||||
const settings = { apiBaseUrl: "", apiKey: "", accessToken: "" };
|
||||
|
||||
export default function FilesToolbarScenario() {
|
||||
const parameters = new URLSearchParams(useLocation().search);
|
||||
const [nextSession, setNextSession] = useState(false);
|
||||
const auth = parameters.has("read-only") ? readOnlyAuth : fullAuth;
|
||||
return <PlatformLanguageProvider preferredLanguageCode={parameters.get("language") ?? "en"} moduleTranslations={[generatedTranslations]}>
|
||||
{parameters.has("session-switch") && <button type="button" onClick={() => setNextSession(true)}>Switch fixture session</button>}
|
||||
<FilesPage settings={nextSession ? { ...settings, accessToken: "fixture-next-session" } : settings}
|
||||
auth={nextSession ? { ...auth, user: { ...auth.user, id: "fixture-next-user" } } : auth} />
|
||||
</PlatformLanguageProvider>;
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
import { useState } from "react";
|
||||
import { Plus } from "lucide-react";
|
||||
import { FormGrid, FormLayout, GridItem } from "../src/components/ContentGrid";
|
||||
import FormField from "../src/components/FormField";
|
||||
import PasswordField from "../src/components/PasswordField";
|
||||
import ToggleSwitch from "../src/components/ToggleSwitch";
|
||||
import TableActionGroup from "../src/components/table/TableActionGroup";
|
||||
import { PlatformLanguageProvider } from "../src/i18n/LanguageContext";
|
||||
import { AttachmentRulesDataGrid } from "../../../govoplan-campaign/webui/src/features/campaigns/components/AttachmentRulesOverlay";
|
||||
import type { AttachmentRule } from "../../../govoplan-campaign/webui/src/features/campaigns/utils/attachments";
|
||||
import { generatedTranslations } from "../../../govoplan-campaign/webui/src/i18n/generatedTranslations";
|
||||
import "../../../govoplan-campaign/webui/src/styles/campaign-workspace.css";
|
||||
|
||||
/** Real shared controls and Campaign attachment editor; no API writes. */
|
||||
export default function FormControlLayoutScenario() {
|
||||
const [secret, setSecret] = useState("");
|
||||
const [checked, setChecked] = useState(false);
|
||||
const [rules, setRules] = useState<AttachmentRule[]>([]);
|
||||
return <PlatformLanguageProvider preferredLanguageCode="en" moduleTranslations={[generatedTranslations]}>
|
||||
<main style={{ padding: 16, minWidth: 0 }}>
|
||||
<h1>Shared form-control layout</h1>
|
||||
<FormGrid columns={2} data-testid="mixed-form-grid">
|
||||
<FormField label="Password" help="Leave blank to retain the configured secret.">
|
||||
<PasswordField value={secret} onValueChange={setSecret} generator />
|
||||
</FormField>
|
||||
<ToggleSwitch label="Remove configured secret" checked={checked} onChange={setChecked} disabled={Boolean(secret)} />
|
||||
<FormField label={"Long translated field label with additional context ".repeat(4)}>
|
||||
<input aria-label="Long label field" />
|
||||
</FormField>
|
||||
<FormField label="Short label"><input aria-label="Short label field" /></FormField>
|
||||
<FormField label="Wrapped field"><input aria-label="Wrapped field" /></FormField>
|
||||
<GridItem><ToggleSwitch label="Wrapped switch" checked={checked} onChange={setChecked} /></GridItem>
|
||||
</FormGrid>
|
||||
<FormLayout columns={2} data-testid="mixed-form-layout" onSubmit={(event) => event.preventDefault()}>
|
||||
<FormField label="Other setting"><input aria-label="Other setting" /></FormField>
|
||||
<ToggleSwitch label="Other switch" checked={checked} onChange={setChecked} />
|
||||
</FormLayout>
|
||||
<h2>Global attachments</h2>
|
||||
<div data-testid="global-attachments">
|
||||
<AttachmentRulesDataGrid id="layout-global-attachments" rules={rules}
|
||||
settings={{ apiBaseUrl: "", accessToken: "", apiKey: "" }} campaignId="fixture-campaign"
|
||||
basePaths={[{ id: "fixture-source", name: "Fixture source", path: "fixtures" }]}
|
||||
onChange={setRules} />
|
||||
</div>
|
||||
<h2>Compact actions resist broad consumer styles</h2>
|
||||
<style>{".fixture-broad-button-style .btn { width: 100%; }"}</style>
|
||||
<div className="fixture-broad-button-style" data-testid="compact-action">
|
||||
<TableActionGroup actions={[{ id: "add", label: "Add fixture item", icon: <Plus />, onClick: () => setChecked(true) }]} />
|
||||
</div>
|
||||
</main>
|
||||
</PlatformLanguageProvider>;
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
import DocsPage from "../../../govoplan-docs/webui/src/features/docs/DocsPage";
|
||||
import { generatedTranslations } from "../../../govoplan-docs/webui/src/i18n/generatedTranslations";
|
||||
import { PlatformLanguageProvider } from "../src/i18n/LanguageContext";
|
||||
|
||||
export default function HelpCenterScenario() {
|
||||
return <PlatformLanguageProvider preferredLanguageCode={new URLSearchParams(window.location.search).get("language") ?? "en"} moduleTranslations={[generatedTranslations]}>
|
||||
<DocsPage settings={{ apiBaseUrl: "", apiKey: "", accessToken: "" }} />
|
||||
</PlatformLanguageProvider>;
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
import { MailProfilePolicyEditor } from "../../../govoplan-mail/webui/src/features/mail/MailProfilePolicyEditor";
|
||||
import { generatedTranslations } from "../../../govoplan-mail/webui/src/i18n/generatedTranslations";
|
||||
import { PlatformLanguageProvider } from "../src/i18n/LanguageContext";
|
||||
import type { MailProfileScope } from "../src/types";
|
||||
import "../../../govoplan-mail/webui/src/styles/mail-profiles.css";
|
||||
|
||||
export default function MailCredentialPolicyScenario() {
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
const scope = (params.get("scope") ?? "tenant") as MailProfileScope;
|
||||
return <PlatformLanguageProvider preferredLanguageCode={params.get("language") ?? "en"} moduleTranslations={[generatedTranslations]}>
|
||||
<MailProfilePolicyEditor settings={{ apiBaseUrl: "", apiKey: "", accessToken: "" }}
|
||||
scopeType={scope} scopeId={scope === "system" || scope === "tenant" ? null : "fixture-target"}
|
||||
profiles={[]} canWrite={!params.has("read-only")} locked={params.has("locked")}
|
||||
onSaved={params.has("refresh-failure") ? async () => { throw new Error("Synthetic dependent refresh failed"); } : undefined} />
|
||||
</PlatformLanguageProvider>;
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
import MailboxPage from "../../../govoplan-mail/webui/src/features/mail/MailboxPage";
|
||||
import { generatedTranslations } from "../../../govoplan-mail/webui/src/i18n/generatedTranslations";
|
||||
import { PlatformLanguageProvider } from "../src/i18n/LanguageContext";
|
||||
import type { AuthInfo } from "../src/types";
|
||||
import "../../../govoplan-mail/webui/src/styles/mail-profiles.css";
|
||||
|
||||
const settings = { apiBaseUrl: "", apiKey: "", accessToken: "" };
|
||||
const auth: AuthInfo = {
|
||||
user: { id: "mail-tree-user", account_id: "mail-tree-account", email: "mail-tree@example.test" },
|
||||
tenant: { id: "mail-tree-tenant", name: "Mail tree fixture", slug: "mail-tree" },
|
||||
scopes: ["mail:mailbox:read", "mail:profile:read", "mail:profile:use"],
|
||||
roles: [], groups: [], profile_loaded: true, roles_loaded: true, groups_loaded: true
|
||||
};
|
||||
|
||||
export default function MailFolderExplorerScenario() {
|
||||
return <PlatformLanguageProvider preferredLanguageCode="en" moduleTranslations={[generatedTranslations]}>
|
||||
<MailboxPage settings={settings} auth={auth} />
|
||||
</PlatformLanguageProvider>;
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import { useState } from "react";
|
||||
import MailboxPage from "../../../govoplan-mail/webui/src/features/mail/MailboxPage";
|
||||
import { generatedTranslations } from "../../../govoplan-mail/webui/src/i18n/generatedTranslations";
|
||||
import { PlatformLanguageProvider } from "../src/i18n/LanguageContext";
|
||||
import type { AuthInfo } from "../src/types";
|
||||
import "../../../govoplan-mail/webui/src/styles/mail-profiles.css";
|
||||
|
||||
const settings = { apiBaseUrl: "", apiKey: "", accessToken: "" };
|
||||
|
||||
export default function MailToolbarScenario() {
|
||||
const parameters = new URLSearchParams(window.location.search);
|
||||
const [tenant, setTenant] = useState("first");
|
||||
const auth: AuthInfo = {
|
||||
user: { id: "mail-toolbar-user", account_id: "mail-toolbar-account", email: "mail-toolbar@example.test" },
|
||||
tenant: { id: `mail-toolbar-${tenant}`, name: "Mailbox toolbar fixture", slug: tenant },
|
||||
scopes: ["mail:mailbox:read", "mail:profile:read", "mail:profile:use", ...(parameters.has("bounce-allowed") ? ["mail:bounce:read"] : [])],
|
||||
roles: [], groups: [], profile_loaded: true, roles_loaded: true, groups_loaded: true
|
||||
};
|
||||
return <PlatformLanguageProvider preferredLanguageCode={parameters.get("language") ?? "en"} moduleTranslations={[generatedTranslations]}>
|
||||
{parameters.has("switch-tenant") && <button type="button" onClick={() => setTenant("second")}>Switch fixture tenant</button>}
|
||||
<MailboxPage settings={settings} auth={auth} />
|
||||
</PlatformLanguageProvider>;
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
// Conformance-only composition: render the owning module, never a test copy.
|
||||
import FilesPage from "../../../govoplan-files/webui/src/features/files/FilesPage";
|
||||
import { generatedTranslations } from "../../../govoplan-files/webui/src/i18n/generatedTranslations";
|
||||
import "../../../govoplan-files/webui/src/styles/file-manager.css";
|
||||
import { PlatformLanguageProvider } from "../src/i18n/LanguageContext";
|
||||
import type { AuthInfo } from "../src/types";
|
||||
|
||||
const fixtureAuth: AuthInfo = {
|
||||
user: { id: "fixture-user", account_id: "fixture-account", email: "fixture@example.test" },
|
||||
tenant: { id: "fixture-tenant", name: "Fixture", slug: "fixture" },
|
||||
scopes: ["files:file:read", "files:file:download", "files:file:upload"],
|
||||
roles: [], groups: [], profile_loaded: true, roles_loaded: true, groups_loaded: true
|
||||
};
|
||||
|
||||
export default function ManagedArchiveScenario({ language = "en", downloadAllowed = true }: { language?: string; downloadAllowed?: boolean }) {
|
||||
const auth = downloadAllowed ? fixtureAuth : {
|
||||
...fixtureAuth, scopes: fixtureAuth.scopes.filter((scope) => scope !== "files:file:download")
|
||||
};
|
||||
return <PlatformLanguageProvider preferredLanguageCode={language} moduleTranslations={[generatedTranslations]}>
|
||||
<FilesPage settings={{ apiBaseUrl: "", apiKey: "", accessToken: "" }} auth={auth} />
|
||||
</PlatformLanguageProvider>;
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
import CommitteePage from "../../../govoplan-committee/webui/src/features/committee/CommitteePage";
|
||||
import VotingPage from "../../../govoplan-voting/webui/src/features/voting/VotingPage";
|
||||
import SchedulingPage from "../../../govoplan-scheduling/webui/src/features/scheduling/SchedulingPage";
|
||||
import RiskCompliancePage from "../../../govoplan-risk-compliance/webui/src/features/riskCompliance/RiskCompliancePage";
|
||||
import OrganizationsPage from "../../../govoplan-organizations/webui/src/features/organizations/OrganizationsPage";
|
||||
import TypedRelationshipsPanel from "../../../govoplan-idm/webui/src/features/TypedRelationshipsPanel";
|
||||
import { generatedTranslations as committeeTranslations } from "../../../govoplan-committee/webui/src/i18n/generatedTranslations";
|
||||
import { generatedTranslations as votingTranslations } from "../../../govoplan-voting/webui/src/i18n/generatedTranslations";
|
||||
import { generatedTranslations as schedulingTranslations } from "../../../govoplan-scheduling/webui/src/i18n/generatedTranslations";
|
||||
import { generatedTranslations as riskTranslations } from "../../../govoplan-risk-compliance/webui/src/i18n/generatedTranslations";
|
||||
import { generatedTranslations as organizationTranslations } from "../../../govoplan-organizations/webui/src/i18n/generatedTranslations";
|
||||
import { generatedTranslations as idmTranslations } from "../../../govoplan-idm/webui/src/i18n/generatedTranslations";
|
||||
import { PlatformLanguageProvider } from "../src/i18n/LanguageContext";
|
||||
import type { AuthInfo } from "../src/types";
|
||||
import "../../../govoplan-committee/webui/src/styles/committee.css";
|
||||
import "../../../govoplan-voting/webui/src/styles/voting.css";
|
||||
import "../../../govoplan-scheduling/webui/src/styles/scheduling.css";
|
||||
import "../../../govoplan-risk-compliance/webui/src/styles/risk-compliance.css";
|
||||
import "../../../govoplan-organizations/webui/src/styles/organizations.css";
|
||||
import "../../../govoplan-idm/webui/src/styles/idm.css";
|
||||
|
||||
const settings = { apiBaseUrl: "", apiKey: "", accessToken: "" };
|
||||
|
||||
export default function ModuleLayoutScenario() {
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
const auth: AuthInfo = {
|
||||
user: { id: "layout-user", account_id: "layout-account", email: "layout@example.test" },
|
||||
tenant: { id: "layout-tenant", name: "Layout fixture", slug: "layout" },
|
||||
scopes: params.has("read-only") ? ["idm:relationship:read"] : [
|
||||
"committee:workspace:write", "voting:ballot:manage", "scheduling:schedule:write",
|
||||
"risk_compliance:sanctions:review", "risk_compliance:sanctions:screen", "risk_compliance:workspace:read",
|
||||
"organizations:model:read", "organizations:model:write", "organizations:unit:write", "organizations:function:write",
|
||||
"idm:relationship:read", "idm:relationship:write"
|
||||
],
|
||||
roles: [], groups: [], profile_loaded: true, roles_loaded: true, groups_loaded: true
|
||||
};
|
||||
const context = { settings, auth };
|
||||
const module = params.get("module-layouts");
|
||||
return <PlatformLanguageProvider preferredLanguageCode={params.get("language") ?? "en"} moduleTranslations={[committeeTranslations, votingTranslations, schedulingTranslations, riskTranslations, organizationTranslations, idmTranslations]}>
|
||||
<div data-testid="module-layout-fixture" style={{ height: "100vh", minWidth: 0 }}>
|
||||
{module === "committee" && <CommitteePage {...context} />}
|
||||
{module === "voting" && <VotingPage {...context} />}
|
||||
{module === "scheduling" && <SchedulingPage {...context} />}
|
||||
{module === "risk" && <RiskCompliancePage {...context} />}
|
||||
{module === "organizations" && <OrganizationsPage {...context} />}
|
||||
{module === "idm" && <TypedRelationshipsPanel {...context} />}
|
||||
</div>
|
||||
</PlatformLanguageProvider>;
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
import { useState } from "react";
|
||||
import Button from "../src/components/Button";
|
||||
import Dialog from "../src/components/Dialog";
|
||||
import MultiSelectFilter from "../src/components/MultiSelectFilter";
|
||||
import { PlatformLanguageProvider } from "../src/i18n/LanguageContext";
|
||||
|
||||
const options = [
|
||||
{ value: "first", label: "First tag" },
|
||||
{ value: "long", label: "X".repeat(80) },
|
||||
{ value: "last", label: "Last tag" }
|
||||
];
|
||||
|
||||
export default function MultiSelectFilterScenario() {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [selection, setSelection] = useState<string[] | null>(null);
|
||||
const filter = <MultiSelectFilter label="Fixture tags" options={options} value={selection} onChange={setSelection} />;
|
||||
return <PlatformLanguageProvider preferredLanguageCode="en">
|
||||
<main className="content-pad">
|
||||
<Button onClick={() => setOpen(true)}>Open filter dialog</Button>
|
||||
<Button>Outside action</Button>
|
||||
{filter}
|
||||
<output aria-label="Selected tags">{selection === null ? "all" : JSON.stringify(selection)}</output>
|
||||
<Dialog open={open} title="Filter owner" onClose={() => setOpen(false)} size="small"
|
||||
panelStyle={{ transform: "translateZ(0)" }}
|
||||
footer={<Button onClick={() => setOpen(false)}>Done</Button>}
|
||||
>
|
||||
<div style={{ overflow: "hidden", maxHeight: 100 }}>
|
||||
<p>The filter escapes this clipped and transformed dialog.</p>
|
||||
{filter}
|
||||
</div>
|
||||
</Dialog>
|
||||
</main>
|
||||
</PlatformLanguageProvider>;
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
import { useState } from "react";
|
||||
import { Folder, LayoutDashboard, Mail } from "lucide-react";
|
||||
import NavigationPreferenceEditor from "../src/components/NavigationPreferenceEditor";
|
||||
import { PlatformLanguageProvider } from "../src/i18n/LanguageContext";
|
||||
import IconRail from "../src/layout/IconRail";
|
||||
import type { NavigationPreferences, PlatformNavItem, ProductAreaContribution } from "../src/types";
|
||||
import type { NavigationPreferenceScope } from "../src/components/navigationPreferenceLayout";
|
||||
|
||||
const items: PlatformNavItem[] = [
|
||||
{ to: "/dashboard", label: "Dashboard", surfaceId: "dashboard", navigationId: "dashboard", icon: LayoutDashboard, navigationLocked: true, navigationLayers: { module: { order: 0, visible: true, locked: false }, system: { order: 0, visible: true, locked: true }, tenant: { order: 0, visible: true, locked: true } } },
|
||||
{ to: "/files", label: "Files", surfaceId: "files", navigationId: "files", icon: Folder, order: 1 },
|
||||
{ to: "/mail", label: "Mail", surfaceId: "mail", navigationId: "mail", icon: Mail, order: 2 }
|
||||
];
|
||||
const areas: ProductAreaContribution[] = [
|
||||
{ id: "content", moduleId: "files", label: "Documents", iconName: "files", surfaceIds: ["files"], order: 1 },
|
||||
{ id: "communication", moduleId: "mail", label: "Communication", iconName: "mail", surfaceIds: ["mail"], order: 2 }
|
||||
];
|
||||
|
||||
export default function NavigationLayoutScenario({ scope = "user", disabled = false, language = "en" }: { scope?: NavigationPreferenceScope; disabled?: boolean; language?: string }) {
|
||||
const [value, setValue] = useState<NavigationPreferences | null>(() => new URLSearchParams(window.location.search).has("unavailable") ? {
|
||||
contract_version: "1", order: ["dashboard", "optional.navigation.absent", "files", "mail"], hidden: [], separators: []
|
||||
} : null);
|
||||
return <PlatformLanguageProvider preferredLanguageCode={language}>
|
||||
<div style={{ display: "grid", gridTemplateColumns: "auto minmax(0, 1fr)", minWidth: 0 }}>
|
||||
<IconRail navItems={items} productAreas={areas} presentation={{ navigation: value }} />
|
||||
<main style={{ minWidth: 0, padding: 16 }}>
|
||||
<h1>Navigation editor fixture</h1>
|
||||
<NavigationPreferenceEditor items={items} productAreas={areas} value={value} onChange={setValue} scope={scope} disabled={disabled} />
|
||||
<output data-testid="navigation-draft" style={{ display: "none" }}>{JSON.stringify(value)}</output>
|
||||
</main>
|
||||
</div>
|
||||
</PlatformLanguageProvider>;
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
import NotificationCenterPage from "../../../govoplan-notifications/webui/src/features/notifications/NotificationCenterPage";
|
||||
import { generatedTranslations } from "../../../govoplan-notifications/webui/src/i18n/generatedTranslations";
|
||||
import { PlatformLanguageProvider } from "../src/i18n/LanguageContext";
|
||||
import type { AuthInfo } from "../src/types";
|
||||
import { useEffect, useState } from "react";
|
||||
import "../../../govoplan-notifications/webui/src/styles/notifications.css";
|
||||
|
||||
const auth: AuthInfo = {
|
||||
user: { id: "filter-user", account_id: "filter-account", email: "filter@example.test" },
|
||||
tenant: { id: "filter-tenant", name: "Filter fixture", slug: "filter" },
|
||||
scopes: ["notifications:notification:read"], roles: [], groups: [],
|
||||
profile_loaded: true, roles_loaded: true, groups_loaded: true,
|
||||
};
|
||||
|
||||
export default function NotificationFilterScenario() {
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
const [account, setAccount] = useState("filter-user");
|
||||
useEffect(() => {
|
||||
const changeAccount = () => setAccount("other-user");
|
||||
window.addEventListener("conformance-notification-account", changeAccount);
|
||||
return () => window.removeEventListener("conformance-notification-account", changeAccount);
|
||||
}, []);
|
||||
const scopedAuth = { ...auth, user: { ...auth.user, id: account },
|
||||
scopes: params.has("write") ? [...auth.scopes, "notifications:notification:write", "notifications:delivery:dispatch"] : auth.scopes };
|
||||
return <PlatformLanguageProvider preferredLanguageCode={new URLSearchParams(window.location.search).get("language") ?? "en"} moduleTranslations={[generatedTranslations]}>
|
||||
<NotificationCenterPage settings={{ apiBaseUrl: "", apiKey: "", accessToken: account }} auth={scopedAuth} />
|
||||
</PlatformLanguageProvider>;
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
import { StrictMode, useState } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import Button from "../src/components/Button";
|
||||
import FormField from "../src/components/FormField";
|
||||
import PasswordField from "../src/components/PasswordField";
|
||||
import { PlatformLanguageProvider } from "../src/i18n/LanguageContext";
|
||||
import "../src/styles/tokens.css";
|
||||
import "../src/styles/layout.css";
|
||||
import "../src/styles/forms.css";
|
||||
import "../src/styles/components.css";
|
||||
import "../src/styles/dialogs.css";
|
||||
import "./conformance.css";
|
||||
|
||||
const GENERATOR_OPTIONS = { length: 24 };
|
||||
|
||||
function PasswordFieldScenario() {
|
||||
const [password, setPassword] = useState("fixture-unchanged-password");
|
||||
const [disabled, setDisabled] = useState(false);
|
||||
const [changes, setChanges] = useState(0);
|
||||
|
||||
return (
|
||||
<main className="conformance-root">
|
||||
<h1>Optional password generator</h1>
|
||||
<FormField label="Editable password">
|
||||
<PasswordField
|
||||
data-testid="editable-password"
|
||||
value={password}
|
||||
disabled={disabled}
|
||||
generator
|
||||
generatorOptions={GENERATOR_OPTIONS}
|
||||
helpContextId="access.authentication.password"
|
||||
helpModuleId="access"
|
||||
onValueChange={(value) => {
|
||||
setPassword(value);
|
||||
setChanges((count) => count + 1);
|
||||
}}
|
||||
/>
|
||||
</FormField>
|
||||
<output data-testid="password-changes">{changes}</output>
|
||||
<Button data-testid="toggle-generator-access" onClick={() => setDisabled((value) => !value)}>
|
||||
{disabled ? "Enable generation" : "Disable generation"}
|
||||
</Button>
|
||||
<FormField label="Sign-in password">
|
||||
<PasswordField value="fixture-sign-in-password" onValueChange={() => undefined} />
|
||||
</FormField>
|
||||
<FormField label="Disabled password">
|
||||
<PasswordField generator disabled value="" onValueChange={() => undefined} />
|
||||
</FormField>
|
||||
<FormField label="Read-only password">
|
||||
<PasswordField generator readOnly value="" onValueChange={() => undefined} />
|
||||
</FormField>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
// Deliberately no Core barrel or other fixture imports: they could eagerly load
|
||||
// the generator and mask a regression in this field's real lazy boundary.
|
||||
createRoot(document.getElementById("root")!).render(
|
||||
<StrictMode>
|
||||
<PlatformLanguageProvider preferredLanguageCode={new URLSearchParams(window.location.search).get("language") ?? "en"}>
|
||||
<PasswordFieldScenario />
|
||||
</PlatformLanguageProvider>
|
||||
</StrictMode>,
|
||||
);
|
||||
@@ -1,25 +1,140 @@
|
||||
// Narrow facade used only by the conformance build. It lets the optional
|
||||
// Quick Access module exercise its real rail without pulling the composed
|
||||
// application's generated module catalogue into this isolated test bundle.
|
||||
export { apiFetch } from "../src/api/client";
|
||||
// Narrow facade used only by the conformance build. It lets optional modules
|
||||
// exercise their real task surfaces without pulling the composed application's
|
||||
// generated module catalogue into this isolated test bundle.
|
||||
export { ApiError, apiDownload, apiFetch, apiGetList, apiPath, apiPost, apiPostJson, apiQuery, apiUrl, authHeaders, csrfToken } from "../src/api/client";
|
||||
export { fetchAuthGroups } from "../src/api/auth";
|
||||
export { default as FormSection } from "../src/components/FormSection";
|
||||
export { mailProfilePatternKeys, mailProfilePolicyLimitKeys } from "../src/api/mailContracts";
|
||||
export type * from "../src/api/mailContracts";
|
||||
export type * from "../src/types";
|
||||
export { default as FieldLabel } from "../src/components/help/FieldLabel";
|
||||
export { default as PasswordField } from "../src/components/PasswordField";
|
||||
export { default as ResourceAccessExplanation } from "../src/components/ResourceAccessExplanation";
|
||||
export { default as ExplorerTree } from "../src/components/ExplorerTree";
|
||||
export type { ExplorerTreeNodeContext } from "../src/components/ExplorerTree";
|
||||
export { default as DataGrid, DataGridEmptyAction, DataGridRowActions, DataGridPaginationBar } from "../src/components/table/DataGrid";
|
||||
export type { DataGridColumn, DataGridQueryState, DataGridListOption } from "../src/components/table/DataGrid";
|
||||
export { default as TableActionGroup } from "../src/components/table/TableActionGroup";
|
||||
export { revisionConflictFromError, threeWayMerge } from "../src/api/concurrency";
|
||||
export { useConcurrencyConflictResolver } from "../src/components/ConcurrencyConflictDialog";
|
||||
export { useRegisterUnsavedChanges, UnsavedChangesProvider } from "../src/components/UnsavedChangesGuard";
|
||||
export type { UnsavedChangesRegistration, UnsavedNavigationAction } from "../src/components/UnsavedChangesGuard";
|
||||
export { useDeltaWatermarks } from "../src/utils/deltaHooks";
|
||||
export { default as ReferenceSelect } from "../src/components/ReferenceSelect";
|
||||
export { unavailableReferenceOption } from "../src/components/ReferenceSelect";
|
||||
export { filterSearchableSelectOptions } from "../src/components/SearchableSelect";
|
||||
export type { ReferenceOptionProvider, ReferenceOption, CredentialReferenceSelectorContext, CredentialReferenceSelectorsUiCapability } from "../src/components/ReferenceSelect";
|
||||
export { apiReferenceOptionProvider } from "../src/platform/referenceProviders";
|
||||
export { fetchResourceAccessExplanation, fetchResourceAccessExplanationSubjects } from "../src/api/resourceAccess";
|
||||
export type { AccessDecisionProvenanceItem, ResourceAccessExplanationUser, ResourceAccessExplanationResponse, ResourceAccessExplanationSubjectsResponse } from "../src/api/resourceAccess";
|
||||
export { default as ActionBlockerHint } from "../src/components/ActionBlockerHint";
|
||||
export type { ActionBlockerReason } from "../src/components/ActionBlockerHint";
|
||||
export { default as MessageDisplayPanel } from "../src/components/MessageDisplayPanel";
|
||||
export type { MessageDisplayAttachment } from "../src/components/MessageDisplayPanel";
|
||||
export { default as GuidedReviewList } from "../src/components/GuidedReviewList";
|
||||
export { default as InlineHelp } from "../src/components/help/InlineHelp";
|
||||
export { default as ActionToolbar } from "../src/components/ActionToolbar";
|
||||
export { ToolbarGroup } from "../src/components/ActionToolbar";
|
||||
export { default as MultiSelectFilter } from "../src/components/MultiSelectFilter";
|
||||
export { default as CountBadge } from "../src/components/CountBadge";
|
||||
export { default as Button } from "../src/components/Button";
|
||||
export { default as Card } from "../src/components/Card";
|
||||
export { default as MetricGrid } from "../src/components/MetricGrid";
|
||||
export { default as MetricCard } from "../src/components/MetricCard";
|
||||
export { default as PageActionBar } from "../src/components/PageActionBar";
|
||||
export { default as PageLayout } from "../src/components/PageLayout";
|
||||
export { default as PageTitle } from "../src/components/PageTitle";
|
||||
export { default as AdminIconButton } from "../src/components/admin/AdminIconButton";
|
||||
export { default as ModuleSubnav } from "../src/layout/ModuleSubnav";
|
||||
export type { ModuleSubnavGroup } from "../src/layout/ModuleSubnav";
|
||||
export { default as DateTimeField } from "../src/components/DateTimeField";
|
||||
export { default as PeoplePicker } from "../src/components/people/PeoplePicker";
|
||||
export type { PeoplePickerItem, PeoplePickerSearch, PeoplePickerSearchGroup } from "../src/components/people/peoplePickerTypes";
|
||||
export type { FormatDateTimeOptions } from "../src/utils/datetime";
|
||||
export { default as SearchableSelect } from "../src/components/SearchableSelect";
|
||||
export type { SearchableSelectOption } from "../src/components/SearchableSelect";
|
||||
export { FormLayout } from "../src/components/ContentGrid";
|
||||
export { apiPatchJson, isApiError } from "../src/api/client";
|
||||
export { hasAnyScope } from "../src/utils/permissions";
|
||||
export { useEffectiveView, useViewSurfaces } from "../src/platform/ViewContext";
|
||||
export { isViewSurfaceVisible } from "../src/platform/views";
|
||||
export { MailServerFolderLookupResultView } from "../src/components/mail/MailServerSettingsPanel";
|
||||
export type { MailServerFolderLookupResult } from "../src/components/mail/MailServerSettingsPanel";
|
||||
export { default as AdminSelectionList } from "../src/components/admin/AdminSelectionList";
|
||||
export { default as AdminPageLayout } from "../src/components/admin/AdminPageLayout";
|
||||
export { adminErrorMessage } from "../src/components/admin/adminUtils";
|
||||
export { default as ConnectionTree } from "../src/components/ConnectionTree";
|
||||
export type { ConnectionTreeColumn } from "../src/components/ConnectionTree";
|
||||
export { default as StageRail } from "../src/components/StageRail";
|
||||
export type { StageRailTone } from "../src/components/StageRail";
|
||||
export { default as PolicyLockedHint } from "../src/components/PolicyLockedHint";
|
||||
export { default as PolicyPathHelp, normalizePolicySourcePathItems } from "../src/components/PolicyPathHelp";
|
||||
export type { NormalizedPolicySourcePathItem } from "../src/components/PolicyPathHelp";
|
||||
export { default as PolicySourcePath } from "../src/components/PolicySourcePath";
|
||||
export type { PolicySourcePathItem } from "../src/components/PolicySourcePath";
|
||||
export { PolicyRow, PolicyTable } from "../src/components/PolicyTable";
|
||||
export { default as MailServerSettingsPanel, hasMailImapSettings, mailImapSettingsPayload, mailServerSecurityOptions, mailSmtpSettingsPayload, mailTextOrNull, mailTransportCredentialsPayload, normalizeMailImapFolderMappings, normalizeMailServerSecurity } from "../src/components/mail/MailServerSettingsPanel";
|
||||
export type { MailServerConnectionTestResult, MailServerCredentialSettings, MailServerImapSettings, MailServerSmtpSettings } from "../src/components/mail/MailServerSettingsPanel";
|
||||
export { mergeDeltaRows } from "../src/utils/delta";
|
||||
export { ReferenceMultiSelect, customReferenceOption, staticReferenceOptionProvider } from "../src/components/ReferenceSelect";
|
||||
export { platformModuleReferenceProvider } from "../src/platform/referenceProviders";
|
||||
export { default as ConfirmDialog } from "../src/components/ConfirmDialog";
|
||||
export { default as ContentSection } from "../src/components/ContentSection";
|
||||
export { default as DescriptionList, DescriptionItem } from "../src/components/DescriptionList";
|
||||
export { default as Dialog } from "../src/components/Dialog";
|
||||
export { DialogForm, DialogSection } from "../src/components/DialogAnatomy";
|
||||
export { default as DismissibleAlert } from "../src/components/DismissibleAlert";
|
||||
export { default as DocumentationHelpLink } from "../src/components/help/DocumentationHelpLink";
|
||||
export type { DocumentationHelpReference } from "../src/components/help/documentationHelp";
|
||||
export { default as FileDropZone } from "../src/components/FileDropZone";
|
||||
export { default as FilterBar } from "../src/components/FilterBar";
|
||||
export { default as FormField } from "../src/components/FormField";
|
||||
export { FormGrid, default as ContentGrid } from "../src/components/ContentGrid";
|
||||
export { default as IconButton } from "../src/components/IconButton";
|
||||
export { default as LoadingFrame } from "../src/components/LoadingFrame";
|
||||
export { useGuardedNavigate } from "../src/components/UnsavedChangesGuard";
|
||||
export { usePlatformLanguage } from "../src/i18n/LanguageContext";
|
||||
export { default as LoadingIndicator } from "../src/components/LoadingIndicator";
|
||||
export { default as PageScrollViewport } from "../src/components/PageScrollViewport";
|
||||
export { default as SegmentedControl } from "../src/components/SegmentedControl";
|
||||
export {
|
||||
default as SelectionList,
|
||||
SelectionListItem,
|
||||
SelectionListItemContent
|
||||
} from "../src/components/SelectionList";
|
||||
export { default as StatePanel } from "../src/components/StatePanel";
|
||||
export { default as StatusBadge } from "../src/components/StatusBadge";
|
||||
export { default as ToggleSwitch } from "../src/components/ToggleSwitch";
|
||||
export {
|
||||
useGuardedNavigate,
|
||||
useUnsavedChanges,
|
||||
useUnsavedDraftGuard
|
||||
} from "../src/components/UnsavedChangesGuard";
|
||||
export {
|
||||
i18nMessage,
|
||||
usePlatformLanguage
|
||||
} from "../src/i18n/LanguageContext";
|
||||
export { usePlatformModuleInstalled, usePlatformUiCapability, usePlatformUiCapabilities, usePlatformModules } from "../src/platform/ModuleContext";
|
||||
export {
|
||||
dispatchQuickAccessResult,
|
||||
quickAccessLaunchState
|
||||
} from "../src/platform/launchContext";
|
||||
export { i18nMessage } from "../src/i18n/LanguageContext";
|
||||
export { hasScope } from "../src/utils/permissions";
|
||||
export { formatDateTime } from "../src/utils/datetime";
|
||||
export { insertAfter, moveArrayItem } from "../src/utils/arrayOrder";
|
||||
export { addressesFromValue, dedupeAddresses, parseMailboxAddressText } from "../src/utils/emailAddresses";
|
||||
export type { MailboxAddress } from "../src/utils/emailAddresses";
|
||||
export { default as WorkspaceActionBar } from "../src/components/WorkspaceActionBar";
|
||||
export { default as WorkspaceFrame } from "../src/components/WorkspaceFrame";
|
||||
export { default as WorkspaceLayout } from "../src/components/WorkspaceLayout";
|
||||
export type {
|
||||
ApiSettings,
|
||||
GlobalSearchProps,
|
||||
SearchContextContribution,
|
||||
SearchContextsUiCapability,
|
||||
DeltaDeletedItem,
|
||||
FilesManagedFileLinkTarget,
|
||||
AuthInfo,
|
||||
PlatformRouteContext,
|
||||
PlatformTranslations,
|
||||
QuickAccessRailProps,
|
||||
QuickAccessToolsUiCapability
|
||||
} from "../src/types";
|
||||
|
||||
export function usePlatformUiCapabilities<T = unknown>(capabilityName: string): T[] {
|
||||
void capabilityName;
|
||||
return [];
|
||||
}
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
import SearchPage from "../../../govoplan-search/webui/src/features/search/SearchPage";
|
||||
import GlobalSearch from "../../../govoplan-search/webui/src/components/GlobalSearch";
|
||||
import { searchFilterTranslations } from "../../../govoplan-search/webui/src/i18n/searchFilterTranslations";
|
||||
import { PlatformLanguageProvider } from "../src/i18n/LanguageContext";
|
||||
import { PlatformModulesProvider } from "../src/platform/ModuleContext";
|
||||
import type { AuthInfo, PlatformWebModule } from "../src/types";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import "../../../govoplan-search/webui/src/styles/search.css";
|
||||
|
||||
const auth: AuthInfo = {
|
||||
user: { id: "search-user", account_id: "search-account", email: "search@example.test" },
|
||||
tenant: { id: "search-tenant", name: "Search fixture", slug: "search" },
|
||||
scopes: ["search:result:read"], roles: [], groups: [], profile_loaded: true, roles_loaded: true, groups_loaded: true,
|
||||
};
|
||||
const modules: PlatformWebModule[] = [
|
||||
{ id: "files", label: "Files", version: "1", uiCapabilities: { "search.contexts": { contexts: [{ id: "files.current", moduleId: "files", label: "Current files", pathPrefixes: ["/files"], resourceTypes: ["file"] }] } } },
|
||||
{ id: "mail", label: "Mail", version: "1" },
|
||||
];
|
||||
|
||||
export default function SearchFiltersScenario() {
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
const [account, setAccount] = useState("search-user");
|
||||
const accessToken = params.has("same-token") ? "search-user" : account;
|
||||
const settings = useMemo(() => ({ apiBaseUrl: "", apiKey: "", accessToken }), [accessToken]);
|
||||
const scopedAuth = useMemo(() => ({ ...auth,
|
||||
user: { ...auth.user, id: account, account_id: `${account}-account` },
|
||||
tenant: { ...auth.tenant, id: `${account}-tenant` },
|
||||
}), [account]);
|
||||
useEffect(() => {
|
||||
const changeAccount = () => setAccount("other-user");
|
||||
window.addEventListener("conformance-search-account", changeAccount);
|
||||
return () => window.removeEventListener("conformance-search-account", changeAccount);
|
||||
}, []);
|
||||
return <PlatformLanguageProvider preferredLanguageCode={params.get("language") ?? "en"} moduleTranslations={[searchFilterTranslations]}>
|
||||
<PlatformModulesProvider modules={modules}>
|
||||
{params.has("overlay") ? <GlobalSearch settings={settings} auth={scopedAuth} /> : <SearchPage settings={settings} auth={scopedAuth} />}
|
||||
</PlatformModulesProvider>
|
||||
</PlatformLanguageProvider>;
|
||||
}
|
||||
@@ -1,7 +1,9 @@
|
||||
import React from "react";
|
||||
import ReactDOM from "react-dom/client";
|
||||
import { BrowserRouter } from "react-router";
|
||||
import { BrowserRouter, Route, Routes } from "react-router";
|
||||
import ConformanceApp from "./ConformanceApp";
|
||||
import { generatedTranslations as formsRuntimeTranslations } from "../../../govoplan-forms-runtime/webui/src/i18n/generatedTranslations";
|
||||
import { productSurfaceTranslations } from "../src/index";
|
||||
import { UnsavedChangesProvider } from "../src/components/UnsavedChangesGuard";
|
||||
import { PlatformLanguageProvider } from "../src/i18n/LanguageContext";
|
||||
import { PlatformModulesProvider } from "../src/platform/ModuleContext";
|
||||
@@ -14,6 +16,7 @@ import "../src/styles/badges.css";
|
||||
import "../src/styles/components.css";
|
||||
import "../src/styles/dialogs.css";
|
||||
import "@govoplan/quick-access-webui/styles/quick-access.css";
|
||||
import "../../../govoplan-forms-runtime/webui/src/styles/forms-runtime.css";
|
||||
import "./conformance.css";
|
||||
|
||||
const theme = new URLSearchParams(window.location.search).get("theme");
|
||||
@@ -35,9 +38,15 @@ ReactDOM.createRoot(document.getElementById("root")!).render(
|
||||
<React.StrictMode>
|
||||
<BrowserRouter>
|
||||
<PlatformModulesProvider modules={CONFORMANCE_MODULES}>
|
||||
<PlatformLanguageProvider preferredLanguageCode="de">
|
||||
<PlatformLanguageProvider
|
||||
preferredLanguageCode={new URLSearchParams(window.location.search).get("language") ?? (["navigation-layout", "managed-archive"].some((key) => new URLSearchParams(window.location.search).has(key)) ? "en" : "de")}
|
||||
moduleTranslations={[formsRuntimeTranslations, productSurfaceTranslations]}>
|
||||
<UnsavedChangesProvider>
|
||||
<ConformanceApp />
|
||||
<Routes>
|
||||
<Route path="/forms/public/:publicId" element={<ConformanceApp />} />
|
||||
<Route path="/forms-runtime/:instanceId" element={<ConformanceApp />} />
|
||||
<Route path="*" element={<ConformanceApp />} />
|
||||
</Routes>
|
||||
</UnsavedChangesProvider>
|
||||
</PlatformLanguageProvider>
|
||||
</PlatformModulesProvider>
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Password field loading conformance</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="./PasswordFieldMain.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,100 @@
|
||||
import { expect, test, type Page } from "@playwright/test";
|
||||
|
||||
async function mockAddresses(page: Page) {
|
||||
const errors: string[] = [];
|
||||
const writes: string[] = [];
|
||||
page.on("pageerror", (error) => errors.push(error.message));
|
||||
const timestamp = "2026-09-01T12:00:00Z";
|
||||
await page.route(url => url.pathname.startsWith("/api/"), async route => {
|
||||
const request = route.request();
|
||||
const path = new URL(request.url()).pathname;
|
||||
if (request.method() !== "GET") writes.push(path);
|
||||
let body: unknown = {};
|
||||
if (path === "/api/v1/addresses/address-books") body = { address_books: [{
|
||||
id: "book-fixture", name: "Fixture address book", scope_type: "tenant",
|
||||
source_kind: "local", read_only: false, contact_count: 0, created_at: timestamp, updated_at: timestamp
|
||||
}] };
|
||||
else if (path === "/api/v1/addresses/address-lists") body = { address_lists: [{
|
||||
id: "list-fixture", address_book_id: "book-fixture", name: "Fixture address list",
|
||||
source_kind: "local", read_only: false, entry_count: 0, created_at: timestamp, updated_at: timestamp
|
||||
}] };
|
||||
else if (path === "/api/v1/addresses/sync-sources") body = { sync_sources: [] };
|
||||
else if (path === "/api/v1/addresses/contacts") body = { contacts: [], total: 0, limit: 50, offset: 0 };
|
||||
else if (path.endsWith("/entries")) body = { entries: [] };
|
||||
return route.fulfill({ json: body });
|
||||
});
|
||||
return { errors, writes };
|
||||
}
|
||||
|
||||
test("Addressbook keeps Reload/New together and moves specialist actions out of the narrow tree header", async ({ page }) => {
|
||||
const fixture = await mockAddresses(page);
|
||||
await page.setViewportSize({ width: 1360, height: 900 });
|
||||
await page.goto("/?address-explorer&language=en&theme=light");
|
||||
const toolbar = page.getByRole("toolbar", { name: "Address book actions", exact: true });
|
||||
const reload = toolbar.locator('[data-page-action-slot="reload"] button');
|
||||
const create = toolbar.getByRole("button", { name: "Add address book", exact: true });
|
||||
await expect(reload).toBeEnabled();
|
||||
await expect(create).toBeVisible();
|
||||
const [reloadBox, createBox] = await Promise.all([reload.boundingBox(), create.boundingBox()]);
|
||||
expect(createBox!.x).toBeGreaterThan(1100);
|
||||
expect(Math.abs(createBox!.y - reloadBox!.y)).toBeLessThan(3);
|
||||
expect(createBox!.x - reloadBox!.x - reloadBox!.width).toBeLessThan(20);
|
||||
await expect(page.locator('.address-tree-header button')).toHaveCount(1);
|
||||
await toolbar.getByRole("button", { name: "Import / export", exact: true }).click();
|
||||
const transfer = page.getByRole("dialog", { name: "Import / export", exact: true });
|
||||
await expect(transfer.getByRole("button", { name: "Import contacts", exact: true })).toBeVisible();
|
||||
await expect(transfer.getByRole("button", { name: "Export address book", exact: true })).toBeVisible();
|
||||
await expect(transfer.getByRole("combobox", { name: "vCard export version" })).toBeVisible();
|
||||
await page.keyboard.press("Escape");
|
||||
await toolbar.getByRole("button", { name: "Connections", exact: true }).click();
|
||||
const connections = page.getByRole("dialog", { name: "Connections", exact: true });
|
||||
await expect(connections.getByRole("button", { name: "Connect CardDAV", exact: true })).toBeVisible();
|
||||
await expect(connections.getByRole("button", { name: "Connect LDAP or Active Directory", exact: true })).toBeVisible();
|
||||
await expect(connections.getByRole("button", { name: "Run sync", exact: true })).toBeDisabled();
|
||||
expect(fixture.errors).toEqual([]);
|
||||
expect(fixture.writes).toEqual([]);
|
||||
});
|
||||
|
||||
test("Addressbook labels select only; folder buttons toggle, including after Reload", async ({ page }) => {
|
||||
const fixture = await mockAddresses(page);
|
||||
await page.goto("/?address-explorer&language=en&theme=light");
|
||||
const root = page.locator('.address-tree-list > .explorer-tree-children > div').first();
|
||||
const label = root.locator(':scope > .explorer-tree-node-wrap > .explorer-tree-node');
|
||||
const folder = root.locator(':scope > .explorer-tree-node-wrap > .explorer-tree-toggle');
|
||||
await expect(folder).toHaveAttribute("aria-expanded", "true");
|
||||
await label.click();
|
||||
await expect(label).toHaveAttribute("aria-current", "true");
|
||||
await expect(folder).toHaveAttribute("aria-expanded", "true");
|
||||
await expect(page.getByText("Select an address book in this group.", { exact: false })).toBeVisible();
|
||||
await folder.click();
|
||||
await expect(folder).toHaveAttribute("aria-expanded", "false");
|
||||
await label.click();
|
||||
await expect(folder).toHaveAttribute("aria-expanded", "false");
|
||||
await page.locator('[data-page-action-slot="reload"] button').click();
|
||||
await expect(page.locator('[data-page-action-slot="reload"] button')).toBeEnabled();
|
||||
await expect(folder).toHaveAttribute("aria-expanded", "false");
|
||||
await expect(label).toHaveAttribute("aria-current", "true");
|
||||
await folder.click();
|
||||
const book = page.locator('.address-tree-list .explorer-tree-node').filter({ hasText: "Fixture address book" });
|
||||
await book.click();
|
||||
await expect(book).toHaveAttribute("aria-current", "true");
|
||||
await page.locator('.address-tree-header').getByRole("button", { name: "Manage", exact: true }).click();
|
||||
const manage = page.getByRole("dialog", { name: "Manage selected book or list", exact: true });
|
||||
await expect(manage.getByRole("button", { name: "Add address list", exact: true })).toBeVisible();
|
||||
await expect(manage.getByRole("button", { name: "Edit address book", exact: true })).toBeVisible();
|
||||
await expect(manage.locator('.form-section-separated').getByRole("button", { name: "Delete address book", exact: true })).toBeVisible();
|
||||
expect(fixture.errors).toEqual([]);
|
||||
expect(fixture.writes).toEqual([]);
|
||||
});
|
||||
|
||||
test("Addressbook specialist controls retain permission blockers in German", async ({ page }) => {
|
||||
const fixture = await mockAddresses(page);
|
||||
await page.goto("/?address-explorer&language=de&read-only&theme=light");
|
||||
await expect(page.getByRole("button", { name: "Adressbuch hinzufügen", exact: true })).toBeDisabled();
|
||||
await page.getByRole("button", { name: "Import / Export", exact: true }).click();
|
||||
const dialog = page.getByRole("dialog", { name: "Import / Export", exact: true });
|
||||
await expect(dialog.getByRole("button", { name: "Kontakte importieren", exact: true })).toBeDisabled();
|
||||
await expect(dialog.getByRole("heading", { name: "In das ausgewählte Adressbuch importieren", exact: true })).toBeVisible();
|
||||
expect(fixture.errors).toEqual([]);
|
||||
expect(fixture.writes).toEqual([]);
|
||||
});
|
||||
@@ -0,0 +1,84 @@
|
||||
import { expect, test, type Page } from "@playwright/test";
|
||||
|
||||
async function install(page: Page) {
|
||||
const errors: string[] = [];
|
||||
page.on("pageerror", error => { errors.push(error.message); console.error("Attachment fixture:", error.message); });
|
||||
let revision = 1;
|
||||
const zip = { enabled: true, archives: [{ id: "zip-1", name: "recipient.zip", method: "zip_standard", password_enabled: true }] };
|
||||
let raw = { campaign: { name: "Fixture" }, template: { subject: "Fixture", text: "" }, server: {},
|
||||
attachments: { base_paths: [{ id: "source-1", name: "Source", path: ".", source: "managed:user:user-1", allow_individual: true }],
|
||||
global: [], zip } };
|
||||
const writes: Record<string, any>[] = [];
|
||||
const version = () => ({ id: "version-attachments", campaign_id: "campaign-attachments", version_number: 1,
|
||||
edit_revision: revision, strong_etag: `"version-attachments:${revision}"`, editor_state: {},
|
||||
current_flow: "manual", current_step: "files", workflow_state: "editing", is_complete: false,
|
||||
updated_at: "2026-09-07T10:00:00Z", raw_json: raw });
|
||||
await page.route((url) => url.pathname.startsWith("/api/"), async route => {
|
||||
const request = route.request(); const url = new URL(request.url());
|
||||
if (request.method() === "GET") {
|
||||
if (url.pathname.endsWith("/workspace/delta")) return route.fulfill({ json: {
|
||||
campaign: { id: "campaign-attachments", name: "Fixture", current_version_id: "version-attachments", status: "draft" },
|
||||
versions: [version()], current_version: version(), summary: null, deleted: [], full: true, has_more: false, watermark: `w${revision}`
|
||||
} });
|
||||
if (url.pathname === "/api/v1/files/spaces") return route.fulfill({ json: { spaces: [{ id: "space-1", label: "My files", space_type: "managed", owner_type: "user", owner_id: "user-1" }] } });
|
||||
if (url.pathname === "/api/v1/files/folders") return route.fulfill({ json: { folders: [], next_cursor: null } });
|
||||
if (url.pathname === "/api/v1/files") return route.fulfill({ json: { files: [], next_cursor: null } });
|
||||
if (url.pathname === "/api/v1/files/delta") return route.fulfill({ json: { files: [], folders: [{ id: "folder-1", owner_type: "user", owner_id: "user-1", path: "letters", name: "letters" }], deleted: [], full: true, has_more: false, watermark: "files-w1" } });
|
||||
if (url.pathname.endsWith("/versions/version-attachments")) return route.fulfill({ json: version() });
|
||||
if (url.pathname.endsWith("/archive-encryption-policy")) return route.fulfill({ json: {
|
||||
available: true, allowed_password_encryption_methods: ["aes"], allowed_password_delivery_channels: ["separate_mail"],
|
||||
policy_hash: "fixture", source_path: [], diagnostics: [], reason: "Legacy ZipCrypto is blocked by policy.", legacy_label: "Legacy ZipCrypto"
|
||||
} });
|
||||
return route.fulfill({ json: {} });
|
||||
}
|
||||
if (request.method() === "POST" && url.pathname.endsWith("/autosave")) {
|
||||
const body = request.postDataJSON(); writes.push(body);
|
||||
raw = body.campaign_json; revision++;
|
||||
return route.fulfill({ json: version() });
|
||||
}
|
||||
return route.abort();
|
||||
});
|
||||
await page.goto("/?campaign-attachments");
|
||||
await expect(page.locator("#campaign-attachment-sources .chooser-display-input")).toBeEnabled();
|
||||
return { writes, errors, zip };
|
||||
}
|
||||
|
||||
test("actual Files chooser opens repeatedly from attachment path by click and keyboard", async ({ page }) => {
|
||||
const fixture = await install(page);
|
||||
const input = page.locator("#campaign-attachment-sources .chooser-display-input");
|
||||
for (const action of ["click", "Enter", "Space", "click"]) {
|
||||
if (action === "click") await input.click();
|
||||
else { await input.focus(); await page.keyboard.press(action); }
|
||||
await expect(page.getByRole("dialog")).toBeVisible();
|
||||
await expect(page.getByRole("dialog").getByRole("button", { name: /Use.*folder|Select.*folder/i })).toBeEnabled();
|
||||
await page.keyboard.press("Escape");
|
||||
await expect(page.getByRole("dialog")).toHaveCount(0);
|
||||
}
|
||||
expect(fixture.writes).toHaveLength(0);
|
||||
expect(fixture.errors).toEqual([]);
|
||||
});
|
||||
|
||||
test("temporarily unavailable Files capability does not silently turn managed paths into text edits", async ({ page }) => {
|
||||
const fixture = await install(page);
|
||||
await page.getByRole("button", { name: "Toggle Files capability" }).click();
|
||||
await expect(page.locator("#campaign-attachment-sources .chooser-display-input")).toBeDisabled();
|
||||
await expect(page.getByText(/The Files browser is currently unavailable/).first()).toBeVisible();
|
||||
await page.getByRole("button", { name: "Toggle Files capability" }).click();
|
||||
await page.locator("#campaign-attachment-sources .chooser-display-input").click();
|
||||
await expect(page.getByRole("dialog")).toBeVisible();
|
||||
await page.keyboard.press("Escape");
|
||||
expect(fixture.writes).toHaveLength(0);
|
||||
expect(fixture.errors).toEqual([]);
|
||||
});
|
||||
|
||||
test("attachment source corrections save without changing or reauthorizing legacy ZIP configuration", async ({ page }) => {
|
||||
const fixture = await install(page);
|
||||
await page.locator('#campaign-attachment-sources input[placeholder="Campaign files"]').fill("Updated source name");
|
||||
await page.getByRole("button", { name: "Save", exact: true }).click();
|
||||
await expect.poll(() => fixture.writes.length).toBe(1);
|
||||
expect(fixture.writes[0].campaign_json.attachments.zip).toEqual(fixture.zip);
|
||||
expect(fixture.writes[0].campaign_json.attachments.base_paths[0].name).toBe("Updated source name");
|
||||
await page.reload();
|
||||
await expect(page.locator('#campaign-attachment-sources input[placeholder="Campaign files"]')).toHaveValue("Updated source name");
|
||||
expect(fixture.errors).toEqual([]);
|
||||
});
|
||||
@@ -0,0 +1,103 @@
|
||||
import { expect, test, type Page } from "@playwright/test";
|
||||
|
||||
async function setup(page: Page, options: { firstFailure?: boolean; delay?: Promise<void> } = {}) {
|
||||
const writes: Record<string, unknown>[] = [];
|
||||
await page.route("**/api/v1/conformance/review-state", async (route) => {
|
||||
writes.push(route.request().postDataJSON());
|
||||
if (options.delay) await options.delay;
|
||||
if (options.firstFailure && writes.length === 1) {
|
||||
await route.fulfill({ status: 409, json: { detail: "Synthetic review revision conflict" } }); return;
|
||||
}
|
||||
await route.fulfill({ json: { saved: true } });
|
||||
});
|
||||
return writes;
|
||||
}
|
||||
|
||||
async function openAttachmentGroup(page: Page, query = "") {
|
||||
page.on("pageerror", (error) => { throw error; });
|
||||
await page.goto(`/?campaign-bulk-review${query}`);
|
||||
await page.getByRole("button", { name: "Open grouped review" }).click();
|
||||
if (await page.getByRole("combobox").isEnabled()) await page.getByRole("combobox").selectOption("same-attachment-condition");
|
||||
return page.getByRole("dialog");
|
||||
}
|
||||
|
||||
test("grouped review accepts only exact selected eligible IDs with shared required reason", async ({ page }) => {
|
||||
const writes = await setup(page);
|
||||
const dialog = await openAttachmentGroup(page);
|
||||
await expect(dialog.getByRole("checkbox")).toHaveCount(3);
|
||||
await expect(dialog.getByRole("button", { name: "Accept 3 selected messages" })).toBeDisabled();
|
||||
await dialog.getByRole("checkbox", { name: "job-001@example.test" }).focus();
|
||||
await page.keyboard.press("Space");
|
||||
await dialog.getByRole("textbox").fill("The optional file is intentionally omitted for these recipients.");
|
||||
await dialog.getByRole("button", { name: "Accept 2 selected messages" }).click();
|
||||
await expect(dialog).not.toBeVisible();
|
||||
expect(writes).toEqual([{ buildToken: "build-one", categoryKey: "same-attachment-condition", jobIds: ["job-000", "job-002"], reason: "The optional file is intentionally omitted for these recipients." }]);
|
||||
});
|
||||
|
||||
test("failed grouped save retains reason and selection, and retries explicitly", async ({ page }) => {
|
||||
const writes = await setup(page, { firstFailure: true });
|
||||
const dialog = await openAttachmentGroup(page);
|
||||
await dialog.getByRole("textbox").fill("Accepted after inspecting frozen evidence.");
|
||||
await dialog.getByRole("checkbox", { name: "job-001@example.test" }).focus();
|
||||
await page.keyboard.press("Space");
|
||||
await dialog.getByRole("button", { name: "Accept 2 selected messages" }).click();
|
||||
await expect(dialog.getByRole("alert")).toContainText("Synthetic review revision conflict");
|
||||
await expect(dialog.getByRole("textbox")).toHaveValue("Accepted after inspecting frozen evidence.");
|
||||
await expect(dialog.getByRole("checkbox", { name: "job-001@example.test" })).not.toBeChecked();
|
||||
expect(writes).toHaveLength(1);
|
||||
await dialog.getByRole("button", { name: "Accept 2 selected messages" }).click();
|
||||
await expect(dialog).not.toBeVisible();
|
||||
expect(writes).toHaveLength(2);
|
||||
expect(writes[1]).toEqual(writes[0]);
|
||||
});
|
||||
|
||||
test("grouped save blocks double clicks and dismissal until acknowledged", async ({ page }) => {
|
||||
let acknowledge!: () => void;
|
||||
const writes = await setup(page, { delay: new Promise<void>((resolve) => { acknowledge = resolve; }) });
|
||||
const dialog = await openAttachmentGroup(page);
|
||||
await dialog.getByRole("textbox").fill("Shared operational exception.");
|
||||
const accept = dialog.getByRole("button", { name: "Accept 3 selected messages" });
|
||||
await accept.evaluate((button: HTMLButtonElement) => { button.click(); button.click(); });
|
||||
await expect.poll(() => writes.length).toBe(1);
|
||||
await expect(dialog.getByRole("button", { name: /^(Cancel|Abbrechen)$/ })).toBeDisabled();
|
||||
await page.keyboard.press("Escape");
|
||||
await expect(dialog).toBeVisible();
|
||||
acknowledge();
|
||||
await expect(dialog).not.toBeVisible();
|
||||
});
|
||||
|
||||
test("grouped review is bounded and clearly counts the remaining matching messages", async ({ page }) => {
|
||||
const writes = await setup(page);
|
||||
const dialog = await openAttachmentGroup(page, "&large");
|
||||
await expect(dialog.getByRole("checkbox")).toHaveCount(200);
|
||||
await expect(dialog).toContainText("Showing 200 of 205 matching unreviewed messages in this category; 200 selected.");
|
||||
await dialog.getByRole("textbox").fill("Confirmed shared exception.");
|
||||
await dialog.getByRole("button", { name: "Accept 200 selected messages" }).click();
|
||||
await expect(dialog).not.toBeVisible();
|
||||
expect(writes[0].jobIds).toHaveLength(200);
|
||||
});
|
||||
|
||||
test("a replaced build or missing permission cannot be bulk accepted", async ({ page }) => {
|
||||
const writes = await setup(page);
|
||||
let dialog = await openAttachmentGroup(page);
|
||||
await dialog.getByRole("textbox").fill("Do not save stale build evidence.");
|
||||
await page.getByTestId("replace-build").evaluate((button: HTMLButtonElement) => button.click());
|
||||
await expect(dialog.getByRole("alert")).toContainText("The build changed");
|
||||
await expect(dialog.getByRole("button", { name: "Accept 3 selected messages" })).toBeDisabled();
|
||||
dialog = await openAttachmentGroup(page, "&read-only");
|
||||
await expect(dialog.getByRole("button", { name: /Accept .* selected messages/ })).toBeDisabled();
|
||||
expect(writes).toHaveLength(0);
|
||||
});
|
||||
|
||||
test("German bulk review labels and category changes preserve explicit scope", async ({ page }) => {
|
||||
const writes = await setup(page);
|
||||
const dialog = await openAttachmentGroup(page, "&language=de");
|
||||
await expect(dialog).toHaveAccessibleName("Gleichartige Prüfbedingungen bestätigen");
|
||||
await expect(dialog.getByRole("button", { name: "3 ausgewählte Nachrichten bestätigen" })).toBeDisabled();
|
||||
await dialog.getByRole("combobox").selectOption("other-condition");
|
||||
await expect(dialog.getByRole("checkbox")).toHaveCount(1);
|
||||
await expect(dialog.getByRole("textbox", { name: "Gemeinsamer Prüfvermerk" })).toHaveValue("");
|
||||
await dialog.getByRole("button", { name: "1 ausgewählte Nachrichten bestätigen" }).click();
|
||||
await expect(dialog).not.toBeVisible();
|
||||
expect(writes[0]).toMatchObject({ categoryKey: "other-condition", jobIds: ["other-category"], reason: "" });
|
||||
});
|
||||
@@ -0,0 +1,107 @@
|
||||
import { expect, test, type Page } from "@playwright/test";
|
||||
|
||||
async function fixture(page: Page, options: { language?: string; scope?: string; readonly?: boolean; ceiling?: number; reject?: number; hold?: boolean } = {}) {
|
||||
let value: number | null = null;
|
||||
let revision = 1;
|
||||
let reject = options.reject;
|
||||
let release: (() => void) | undefined;
|
||||
let rejectRead = false;
|
||||
const writes: any[] = [];
|
||||
const errors: string[] = [];
|
||||
const inherited = options.ceiling ?? 25;
|
||||
const maximum = options.ceiling ?? 500;
|
||||
page.on("pageerror", (cause) => errors.push(cause.message));
|
||||
const state = () => ({ scope: options.scope ?? "system", synchronous_send_max_recipients: value,
|
||||
revision: String(revision).padStart(64, "0"), max_configurable_recipients: maximum,
|
||||
effective_max_recipients: value ?? inherited, inherited_max_recipients: inherited,
|
||||
absolute_max_recipients: 500, deployment_ceiling_explicit: Boolean(options.ceiling), deployment_max_recipients: maximum });
|
||||
await page.route((url) => url.pathname.startsWith("/api/"), async (route) => {
|
||||
if (!route.request().url().includes("/campaigns/settings/delivery-policy/")) return route.fulfill({ status: 404, json: { detail: "Not found" } });
|
||||
if (route.request().method() === "GET") return route.fulfill(rejectRead ? { status: 503, json: { detail: "Fixture reload unavailable" } } : { json: state() });
|
||||
expect(route.request().method()).toBe("PUT");
|
||||
const body = route.request().postDataJSON(); writes.push(body);
|
||||
if (options.hold) await new Promise<void>((resolve) => { release = resolve; });
|
||||
if (reject) {
|
||||
const status = reject; reject = undefined;
|
||||
return route.fulfill({ status, json: { detail: "Fixture policy save rejected; draft was not saved" } });
|
||||
}
|
||||
value = body.synchronous_send_max_recipients; revision += 1;
|
||||
return route.fulfill({ json: state() });
|
||||
});
|
||||
await page.goto(`/?campaign-delivery-policy&language=${options.language ?? "en"}&scope=${options.scope ?? "system"}${options.readonly ? "&readonly" : ""}`);
|
||||
await expect(page.getByRole("spinbutton")).toHaveValue(String(inherited));
|
||||
return { writes, errors, release: () => release?.(), failRead: () => { rejectRead = true; } };
|
||||
}
|
||||
|
||||
for (const language of ["en", "de"]) {
|
||||
test(`administrator deliberately raises implicit 25 to 200 and reloads saved policy in ${language}`, async ({ page }) => {
|
||||
const f = await fixture(page, { language });
|
||||
const save = page.getByRole("button", { name: language === "de" ? "Speichern" : "Save", exact: true });
|
||||
await expect(save).toBeDisabled();
|
||||
await page.getByRole("checkbox").press("Space");
|
||||
await page.getByRole("spinbutton").fill("200");
|
||||
await save.click();
|
||||
await expect(save).toBeDisabled();
|
||||
expect(f.writes).toEqual([{ synchronous_send_max_recipients: 200, expected_revision: "1".padStart(64, "0") }]);
|
||||
await page.getByRole("button", { name: language === "de" ? "Gespeicherte Versandrichtlinie neu laden" : "Reload saved delivery policy", exact: true }).click();
|
||||
await expect(page.getByRole("spinbutton")).toHaveValue("200");
|
||||
await expect(page.getByRole("checkbox")).not.toBeChecked();
|
||||
expect(f.errors).toEqual([]);
|
||||
});
|
||||
}
|
||||
|
||||
for (const status of [422, 409]) {
|
||||
test(`policy save ${status} retains draft for an explicit retry`, async ({ page }) => {
|
||||
const f = await fixture(page, { reject: status });
|
||||
await page.getByRole("checkbox").press("Space");
|
||||
await page.getByRole("spinbutton").fill("200");
|
||||
await page.getByRole("button", { name: "Save", exact: true }).click();
|
||||
await expect(page.getByRole("alert").filter({ hasText: "Fixture policy save rejected" })).toBeVisible();
|
||||
await expect(page.getByRole("spinbutton")).toHaveValue("200");
|
||||
await page.getByRole("button", { name: "Save", exact: true }).click();
|
||||
await expect(page.getByRole("button", { name: "Save", exact: true })).toBeDisabled();
|
||||
expect(f.writes).toHaveLength(2);
|
||||
expect(f.writes[1].synchronous_send_max_recipients).toBe(200);
|
||||
});
|
||||
}
|
||||
|
||||
test("explicit ceiling prevents invalid save and clearing override submits inheritance", async ({ page }) => {
|
||||
const f = await fixture(page, { scope: "tenant", ceiling: 40 });
|
||||
await page.getByRole("checkbox").press("Space");
|
||||
await page.getByRole("spinbutton").fill("41");
|
||||
await expect(page.getByRole("button", { name: "Save", exact: true })).toBeDisabled();
|
||||
expect(f.writes).toHaveLength(0);
|
||||
await page.getByRole("spinbutton").fill("0");
|
||||
await page.getByRole("button", { name: "Save", exact: true }).click();
|
||||
await expect(page.getByRole("button", { name: "Save", exact: true })).toBeDisabled();
|
||||
await page.getByRole("checkbox").press("Space");
|
||||
await page.getByRole("button", { name: "Save", exact: true }).click();
|
||||
await expect(page.getByRole("button", { name: "Save", exact: true })).toBeDisabled();
|
||||
expect(f.writes.map((body) => body.synchronous_send_max_recipients)).toEqual([0, null]);
|
||||
});
|
||||
|
||||
test("read-only policy cannot be edited or saved", async ({ page }) => {
|
||||
const f = await fixture(page, { readonly: true });
|
||||
await expect(page.getByRole("checkbox")).toBeDisabled();
|
||||
await expect(page.getByRole("spinbutton")).toBeDisabled();
|
||||
await expect(page.getByRole("button", { name: "Save", exact: true })).toBeDisabled();
|
||||
expect(f.writes).toHaveLength(0);
|
||||
});
|
||||
|
||||
test("pending policy save disables edits and reload; failed refresh preserves acknowledged value", async ({ page }) => {
|
||||
const f = await fixture(page, { hold: true });
|
||||
await page.getByRole("checkbox").press("Space");
|
||||
await page.getByRole("spinbutton").fill("200");
|
||||
await page.getByRole("button", { name: "Save", exact: true }).click();
|
||||
await expect.poll(() => f.writes.length).toBe(1);
|
||||
await expect(page.getByRole("spinbutton")).toBeDisabled();
|
||||
await expect(page.getByRole("button", { name: "Reload saved delivery policy", exact: true })).toBeDisabled();
|
||||
f.release();
|
||||
await expect(page.getByRole("spinbutton")).toBeEnabled();
|
||||
f.failRead();
|
||||
await page.getByRole("button", { name: "Reload saved delivery policy", exact: true }).click();
|
||||
await expect(page.getByRole("alert").filter({ hasText: "Fixture reload unavailable" })).toBeVisible();
|
||||
await expect(page.getByRole("spinbutton")).toHaveValue("200");
|
||||
await expect(page.getByRole("button", { name: "Save", exact: true })).toBeDisabled();
|
||||
expect(f.writes).toHaveLength(1);
|
||||
});
|
||||
@@ -0,0 +1,187 @@
|
||||
import { expect, test, type Page } from "@playwright/test";
|
||||
|
||||
async function install(page: Page, options: {
|
||||
kind?: "smtp" | "imap"; language?: "en" | "de"; holdWrite?: Promise<void>; interrupt?: boolean; failRefresh?: boolean;
|
||||
diagnostics?: boolean; authoring?: boolean; holdPreview?: Promise<void>;
|
||||
} = {}) {
|
||||
const kind = options.kind ?? "smtp";
|
||||
const errors: string[] = []; page.on("pageerror", error => { errors.push(error.message); console.error("Delivery fixture:", error.message); });
|
||||
const writes: { path: string; payload: Record<string, any> }[] = [];
|
||||
const wholeReads: string[] = [];
|
||||
let progressReads = 0; let previews = 0; let acknowledged = false; let active = false; let validated = false;
|
||||
const jobs = Array.from({ length: 4 }, (_value, index) => ({ id: `delivery-job-${index + 1}`, entry_index: index + 1, entry_id: `entry-${index + 1}`,
|
||||
recipient_email: `recipient-${index + 1}@example.test`, subject: `Delivery message ${index + 1}`, build_status: "built", validation_status: "ready", queue_status: "draft",
|
||||
send_status: kind === "imap" ? "smtp_accepted" : "not_queued", imap_status: options.diagnostics ? ["pending", "appending", "failed", "outcome_unknown"][index] : kind === "imap" ? "pending" : "not_requested",
|
||||
resolved_recipients: { to: [{ email: `recipient-${index + 1}@example.test` }] }, issues: [], attachments: [], review_decision: { eligible: false }, attempt_count: kind === "imap" ? 1 : 0
|
||||
}));
|
||||
const version = () => ({ id: "delivery-version", campaign_id: "delivery-campaign", version_number: 1, edit_revision: 1, strong_etag: '"delivery-version:1"',
|
||||
review_build_token: "delivery-build", locked_at: options.authoring && !validated ? null : "2026-09-07T10:00:00Z", workflow_state: options.authoring && !validated ? "draft" : "built",
|
||||
validation_summary: options.authoring && !validated ? null : { ok: true, warning_count: 0, error_count: 0, issues: [] },
|
||||
build_summary: options.authoring ? null : { built_count: 4, blocked_count: 0 }, execution_snapshot_hash: "frozen-snapshot",
|
||||
raw_json: { campaign: { name: "Delivery fixture" }, server: { mail_profile_id: "profile-fixture" },
|
||||
delivery: { rate_limit: { messages_per_minute: 30 }, imap_append_sent: { enabled: kind === "imap", folder: "Sent" } },
|
||||
entries: { inline: jobs.map(job => ({ id: job.entry_id, email: job.recipient_email, active: true })) } },
|
||||
editor_state: { review_send: { review_build_token: "delivery-build", inspection_complete: true, reviewed_message_keys: [], issue_decisions: [] } }
|
||||
});
|
||||
const baseJobs = (url?: URL) => {
|
||||
const states = url?.searchParams.getAll("imap_status") ?? [];
|
||||
const selected = states.length ? jobs.filter(job => states.includes(job.imap_status)) : jobs;
|
||||
return { jobs: selected, page: 1, page_size: 200, total: selected.length, total_unfiltered: 4, pages: 1, counts: {}, filtered_counts: {},
|
||||
review: { blocking_count: 0, required_count: 0, bulk_acceptable_count: 0, reviewed_required_count: 0, inspection_complete: true } };
|
||||
};
|
||||
await page.route(url => url.pathname.startsWith("/api/"), async route => {
|
||||
const request = route.request(); const url = new URL(request.url());
|
||||
if (request.method() === "GET") {
|
||||
if (/\/(workspace\/delta|summary|jobs|jobs\/delta)$/.test(url.pathname)) wholeReads.push(url.pathname);
|
||||
if (url.pathname.endsWith("/workspace/delta")) return route.fulfill({ json: {
|
||||
campaign: { id: "delivery-campaign", name: "Delivery fixture", current_version_id: "delivery-version", status: "draft" },
|
||||
versions: [version()], current_version: version(), summary: { cards: { jobs_total: 4, sent: kind === "imap" ? 4 : 0, failed: 0 },
|
||||
delivery: { background_workers_enabled: false }, status_counts: { send: kind === "imap" ? { smtp_accepted: 4 } : { not_queued: 4 },
|
||||
imap: kind === "imap" ? options.diagnostics ? { pending: 1, appending: 1, failed: 1, outcome_unknown: 1 } : { pending: 4 } : {} }, attachments: {} },
|
||||
deleted: [], full: true, has_more: false, watermark: "delivery-workspace"
|
||||
} });
|
||||
if (url.pathname.endsWith("/delivery-options")) return route.fulfill({ json: { worker_queue_available: false,
|
||||
synchronous_send: { allowed: true, eligible_recipient_job_count: 4, policy: { max_recipient_jobs: 200 } }, approval_gate: { configured: false, available: false } } });
|
||||
if (url.pathname.endsWith("/delivery-progress")) {
|
||||
progressReads++;
|
||||
if (acknowledged && options.failRefresh) return route.fulfill({ status: 503, json: { detail: "Fixture progress unavailable" } });
|
||||
return route.fulfill({ json: { campaign_id: "delivery-campaign", version_id: "delivery-version", generated_at: "2026-09-07T10:00:00Z", total_jobs: 4,
|
||||
smtp: { total: 4, processed: acknowledged ? 4 : 1, accepted: acknowledged || kind === "imap" ? 4 : 1, active: active && kind === "smtp" ? 1 : 0, pending: acknowledged ? 0 : active ? 2 : 3, failed: 0, outcome_unknown: 0, excluded: 0, paused: 0, cancelled: 0 },
|
||||
imap: { total: 4, processed: acknowledged ? 4 : 1, appended: acknowledged ? 4 : 1, active: active && kind === "imap" ? 1 : 0, pending: acknowledged ? 0 : active ? 2 : 3, failed: 0, outcome_unknown: 0, excluded: 0 },
|
||||
status_counts: { send: {}, queue: {}, imap: {} }
|
||||
} });
|
||||
}
|
||||
if (url.pathname.endsWith("/jobs/delta")) {
|
||||
if (acknowledged && options.failRefresh) return route.fulfill({ status: 503, json: { detail: "Fixture diagnostics unavailable" } });
|
||||
return route.fulfill({ json: { ...baseJobs(url), full: true, has_more: false, deleted: [], watermark: "diagnostic-watermark" } });
|
||||
}
|
||||
if (url.pathname.endsWith("/jobs")) {
|
||||
if (acknowledged && options.failRefresh) return route.fulfill({ status: 503, json: { detail: "Fixture diagnostics unavailable" } });
|
||||
return route.fulfill({ json: baseJobs(url) });
|
||||
}
|
||||
return route.fulfill({ json: {} });
|
||||
}
|
||||
if (url.pathname.endsWith("/attachments/preview")) {
|
||||
previews++;
|
||||
if (previews === 1 && options.holdPreview) await options.holdPreview;
|
||||
const unlinked = options.authoring && previews >= 2 && !validated;
|
||||
const file = { id: "newly-unlinked-file", filename: "letter.pdf", display_path: "letters/letter.pdf", linked_to_campaign: false };
|
||||
return route.fulfill({ json: { campaign_id: "delivery-campaign", version_id: "delivery-version", shared_file_count: 0,
|
||||
rules: [], linkable_files: unlinked ? [file] : [], unused_shared_files: [] } });
|
||||
}
|
||||
const payload = request.postDataJSON(); writes.push({ path: url.pathname, payload });
|
||||
if (url.pathname.endsWith("/validate")) { validated = true; return route.fulfill({ json: { ok: true, issues: [] } }); }
|
||||
if (url.pathname.endsWith("/send-now") || url.pathname.endsWith("/append-sent")) {
|
||||
active = true;
|
||||
if (options.holdWrite) await options.holdWrite;
|
||||
active = false;
|
||||
if (options.interrupt) return route.fulfill({ status: 503, json: { detail: "Fixture request interrupted; verify stored outcomes." } });
|
||||
acknowledged = true;
|
||||
for (const job of jobs) { job.send_status = "smtp_accepted"; if (kind === "imap") job.imap_status = "appended"; }
|
||||
return route.fulfill({ json: { result: { attempted_count: 4, sent_count: 4, failed_count: 0, outcome_unknown_count: 0, paused_count: 0,
|
||||
pending_count: 4, appended_count: 4, processed_count: 4, results: [] } } });
|
||||
}
|
||||
return route.abort();
|
||||
});
|
||||
await page.goto(`/?campaign-delivery-progress&language=${options.language ?? "en"}`);
|
||||
if (options.authoring) await expect.poll(() => previews).toBe(1);
|
||||
else await expect(page.getByRole("table", { name: "campaign-delivery-campaign-workflow-built-messages", exact: true }).getByText("Delivery message 1", { exact: true })).toBeVisible();
|
||||
return { writes, wholeReads, errors, get progressReads() { return progressReads; }, get previews() { return previews; } };
|
||||
}
|
||||
|
||||
async function start(page: Page, kind: "smtp" | "imap") {
|
||||
if (kind === "smtp") {
|
||||
await page.getByRole("button", { name: /^(Send now|Jetzt senden)$/ }).click();
|
||||
await page.getByRole("alertdialog").getByRole("button", { name: /^(Send now|Jetzt senden)$/ }).click();
|
||||
} else await page.getByRole("button", { name: /^(Append pending IMAP now|Ausstehende IMAP-Kopien jetzt anhängen)$/ }).click();
|
||||
}
|
||||
|
||||
for (const language of ["en", "de"] as const) for (const kind of ["smtp", "imap"] as const) test(`${language}: actual ${kind} request shows live active progress without refreshing the underlying workflow`, async ({ page }) => {
|
||||
let release!: () => void; const held = new Promise<void>(resolve => { release = resolve; });
|
||||
const fixture = await install(page, { kind, language, holdWrite: held });
|
||||
await start(page, kind);
|
||||
const dialog = page.getByRole("dialog");
|
||||
await expect(dialog).toBeVisible();
|
||||
await expect.poll(() => fixture.writes.length).toBe(1);
|
||||
const wholeReads = fixture.wholeReads.length;
|
||||
await expect.poll(() => fixture.progressReads).toBeGreaterThanOrEqual(2);
|
||||
await expect(dialog.getByText(language === "de" ? "In Bearbeitung" : "In progress", { exact: true })).toBeVisible();
|
||||
await expect(dialog.getByRole("status").first()).toContainText(/1.*4/);
|
||||
expect(fixture.wholeReads.length).toBe(wholeReads);
|
||||
await page.keyboard.press("Escape"); await expect(dialog).toBeVisible();
|
||||
expect(fixture.writes).toHaveLength(1);
|
||||
release();
|
||||
await expect(dialog.getByRole("button", { name: /Close|Schließen/, exact: true }).last()).toBeEnabled();
|
||||
expect(fixture.errors).toEqual([]);
|
||||
});
|
||||
|
||||
for (const kind of ["smtp", "imap"] as const) test(`${kind}: failed display refresh preserves acknowledged success`, async ({ page }) => {
|
||||
const fixture = await install(page, { kind, failRefresh: true });
|
||||
await start(page, kind);
|
||||
const dialog = page.getByRole("dialog");
|
||||
await expect(dialog.getByRole("button", { name: /Close|Schließen/, exact: true }).last()).toBeEnabled();
|
||||
await expect(dialog.getByText(/The request finished|Die Anfrage ist abgeschlossen/i).first()).toBeVisible();
|
||||
await dialog.getByRole("button", { name: /Close|Schließen/, exact: true }).last().click();
|
||||
await expect(page.getByText(kind === "smtp" ? /Send finished\. SMTP accepted 4/ : /IMAP append processed 4 job/)).toBeVisible();
|
||||
expect(fixture.writes).toHaveLength(1); expect(fixture.errors).toEqual([]);
|
||||
});
|
||||
|
||||
test("an interrupted send remains uncertain and is never replayed by progress polling", async ({ page }) => {
|
||||
const fixture = await install(page, { interrupt: true });
|
||||
await start(page, "smtp");
|
||||
const dialog = page.getByRole("dialog");
|
||||
await expect(dialog.getByText("Fixture request interrupted; verify stored outcomes.", { exact: false })).toBeVisible();
|
||||
await expect.poll(() => fixture.progressReads).toBeGreaterThanOrEqual(2);
|
||||
expect(fixture.writes).toHaveLength(1); expect(fixture.errors).toEqual([]);
|
||||
});
|
||||
|
||||
test("IMAP diagnostics use shared SMTP and IMAP list filters", async ({ page }) => {
|
||||
const fixture = await install(page, { kind: "imap", diagnostics: true });
|
||||
await page.getByRole("button", { name: /^(Load IMAP diagnostics|IMAP-Diagnose laden)$/ }).click();
|
||||
const table = page.getByRole("table", { name: "campaign-delivery-campaign-workflow-imap-diagnostics", exact: true });
|
||||
await expect(table.getByText("recipient-1@example.test", { exact: true })).toBeVisible();
|
||||
const filterButtons = table.getByRole("button", { name: /^Filter (SMTP|IMAP)$/ });
|
||||
await expect(filterButtons).toHaveCount(2);
|
||||
await filterButtons.last().click();
|
||||
await expect(page.getByRole("checkbox", { name: /Pending|Ausstehend/, exact: true })).toBeVisible();
|
||||
await expect(page.getByRole("checkbox", { name: /Outcome uncertain|Ergebnis ungewiss/, exact: true })).toBeVisible();
|
||||
await page.getByRole("button", { name: /^(Deselect all|Alle abwählen)$/ }).click();
|
||||
await page.getByRole("checkbox", { name: "Pending", exact: true }).check();
|
||||
await page.keyboard.press("Escape");
|
||||
await expect(table.getByText("recipient-1@example.test", { exact: true })).toBeVisible();
|
||||
await expect(table.getByText("recipient-2@example.test", { exact: true })).toHaveCount(0);
|
||||
await table.getByRole("button", { name: "Filter SMTP", exact: true }).click();
|
||||
await expect(page.getByRole("checkbox", { name: "SMTP accepted", exact: true })).toBeVisible();
|
||||
expect(fixture.writes).toHaveLength(0); expect(fixture.errors).toEqual([]);
|
||||
});
|
||||
|
||||
test("acknowledged IMAP append refreshes pending diagnostics immediately", async ({ page }) => {
|
||||
const fixture = await install(page, { kind: "imap" });
|
||||
await page.getByRole("button", { name: "Load IMAP diagnostics", exact: true }).click();
|
||||
const table = page.getByRole("table", { name: "campaign-delivery-campaign-workflow-imap-diagnostics", exact: true });
|
||||
await expect(table.getByText("recipient-1@example.test", { exact: true })).toBeVisible();
|
||||
await start(page, "imap");
|
||||
const dialog = page.getByRole("dialog");
|
||||
await expect(dialog.getByRole("button", { name: "Close", exact: true }).last()).toBeEnabled();
|
||||
await dialog.getByRole("button", { name: "Close", exact: true }).last().click();
|
||||
await expect(table).toHaveCount(0);
|
||||
await expect(page.getByText(/IMAP append processed 4 job/)).toBeVisible();
|
||||
expect(fixture.writes).toHaveLength(1); expect(fixture.errors).toEqual([]);
|
||||
});
|
||||
|
||||
test("lock waits for attachment preview and rechecks fresh unlinked matches before validation", async ({ page }) => {
|
||||
let release!: () => void; const held = new Promise<void>(resolve => { release = resolve; });
|
||||
const fixture = await install(page, { authoring: true, holdPreview: held });
|
||||
const lock = page.getByRole("button", { name: /^(Lock and validate|Sperren und validieren)$/ });
|
||||
await expect(lock).toBeDisabled(); expect(fixture.writes).toHaveLength(0);
|
||||
release(); await expect(lock).toBeEnabled();
|
||||
await lock.click();
|
||||
await expect.poll(() => fixture.previews).toBeGreaterThanOrEqual(2);
|
||||
await expect(page.getByRole("alertdialog")).toBeVisible();
|
||||
expect(fixture.writes).toHaveLength(0);
|
||||
await page.getByRole("alertdialog").getByRole("button", { name: /^(Link and lock|Verknüpfen und sperren)$/ }).click();
|
||||
await expect.poll(() => fixture.writes.length).toBe(1);
|
||||
expect(fixture.writes[0].path).toMatch(/\/validate$/);
|
||||
expect(fixture.writes[0].payload.link_unshared_matches).toBe(true);
|
||||
expect(fixture.errors).toEqual([]);
|
||||
});
|
||||
@@ -0,0 +1,196 @@
|
||||
import { expect, test, type Page } from "@playwright/test";
|
||||
|
||||
const zip = { enabled: true, archives: [{
|
||||
id: "legacy-zip", name: "Existing recipient archive", method: "zip_standard",
|
||||
password_enabled: true, password_delivery_channel: "separate_mail"
|
||||
}] };
|
||||
const defaultCredential = {
|
||||
id: "credential-default", name: "Default account", is_active: true, is_default: true,
|
||||
public_data: { username: "sender@example.test" }
|
||||
};
|
||||
const alternateCredential = {
|
||||
id: "credential-alternate", name: "Explicit alternate account", is_active: true, is_default: false,
|
||||
public_data: { username: "alternate@example.test" }
|
||||
};
|
||||
const inactiveCredential = {
|
||||
id: "credential-inactive", name: "Inactive account", is_active: false, is_default: false,
|
||||
public_data: { username: "inactive@example.test" }
|
||||
};
|
||||
const profile = {
|
||||
id: "profile-1", name: "Campaign delivery", scope_type: "tenant", scope_id: "tenant-1", is_active: true,
|
||||
servers: [{
|
||||
id: "smtp-1", name: "Active SMTP server", protocol: "smtp", is_active: true, is_default: true,
|
||||
config: { host: "smtp.example.test", port: 587, security: "starttls" },
|
||||
credentials: [defaultCredential, alternateCredential, inactiveCredential]
|
||||
}, {
|
||||
id: "smtp-inactive", name: "Inactive SMTP server", protocol: "smtp", is_active: false, is_default: false,
|
||||
config: { host: "inactive.example.test", port: 587, security: "starttls" },
|
||||
credentials: [defaultCredential]
|
||||
}]
|
||||
};
|
||||
|
||||
async function install(page: Page, options: {
|
||||
language?: "en" | "de";
|
||||
selectedServer?: Record<string, string>;
|
||||
excludeProfile?: boolean;
|
||||
requireCredential?: boolean;
|
||||
} = {}) {
|
||||
const errors: string[] = [];
|
||||
page.on("pageerror", (error) => { errors.push(error.message); });
|
||||
let revision = 1;
|
||||
let migrationRequired = true;
|
||||
let raw: Record<string, unknown> = {
|
||||
version: "1.0", campaign: { id: "campaign-mail", name: "Mail repair fixture", mode: "send" },
|
||||
server: options.selectedServer ?? { mail_profile_id: "profile-1", smtp_server_id: "smtp-1" },
|
||||
template: { subject: "Fixture subject", text: "Fixture body" },
|
||||
recipients: { from: [{ email: "sender@example.test" }] }, entries: { inline: [] },
|
||||
attachments: { zip }, delivery: { imap_append_sent: { enabled: false, folder: "auto" } }
|
||||
};
|
||||
const writes: Record<string, any>[] = [];
|
||||
const reads: URL[] = [];
|
||||
const unexpectedWrites: string[] = [];
|
||||
const version = () => ({
|
||||
id: "version-mail", campaign_id: "campaign-mail", version_number: 2,
|
||||
schema_version: "1.0", edit_revision: revision, strong_etag: `"version-mail:${revision}"`,
|
||||
current_flow: "manual", current_step: "mail-settings", workflow_state: "editing",
|
||||
is_complete: false, editor_state: { created_from: "minimal_campaign" },
|
||||
user_lock_state: null, locked_at: null, published_at: null,
|
||||
created_at: "2026-09-07T10:00:00Z", updated_at: "2026-09-07T10:00:00Z",
|
||||
raw_json: raw, mail_profile_migration_required: migrationRequired
|
||||
});
|
||||
await page.route((url) => url.pathname.startsWith("/api/"), async (route) => {
|
||||
const request = route.request(); const url = new URL(request.url());
|
||||
if (request.method() === "GET") {
|
||||
reads.push(url);
|
||||
if (url.pathname.endsWith("/workspace/delta")) return route.fulfill({ json: {
|
||||
campaign: { id: "campaign-mail", name: "Mail repair fixture", current_version_id: "version-mail", status: "draft" },
|
||||
versions: [version()], current_version: version(), summary: null, deleted: [],
|
||||
watermark: `mail-watermark-${revision}`, has_more: false, full: true
|
||||
} });
|
||||
if (url.pathname === "/api/v1/mail/profiles") return route.fulfill({ json: {
|
||||
profiles: options.excludeProfile ? [] : [profile]
|
||||
} });
|
||||
if (url.pathname.endsWith("/versions/version-mail")) return route.fulfill({ json: version() });
|
||||
return route.fulfill({ json: {} });
|
||||
}
|
||||
if (request.method() === "POST" && url.pathname.endsWith("/versions/version-mail/autosave")) {
|
||||
const body = request.postDataJSON(); writes.push(body);
|
||||
if (options.requireCredential && !body.campaign_json.server.smtp_credential_id) {
|
||||
return route.fulfill({ status: 422, json: {
|
||||
detail: "Campaign delivery cannot use the selected profile because the effective SMTP credential policy requires an explicit credential selection for this campaign."
|
||||
} });
|
||||
}
|
||||
if (body.base_revision !== revision) return route.fulfill({ status: 409, json: { detail: {
|
||||
code: "revision_conflict", resource: { type: "campaign_version", id: "version-mail" },
|
||||
current_revision: revision, submitted_base_revision: body.base_revision
|
||||
} } });
|
||||
raw = body.campaign_json;
|
||||
revision += 1;
|
||||
if (body.migrate_legacy_mail_settings) migrationRequired = false;
|
||||
return route.fulfill({ json: version() });
|
||||
}
|
||||
unexpectedWrites.push(`${request.method()} ${url.pathname}`);
|
||||
return route.abort();
|
||||
});
|
||||
await page.goto(`/?campaign-mail-settings&language=${options.language ?? "en"}`);
|
||||
await expect(page.getByRole("combobox", { name: "SMTP credential", exact: true })).toBeVisible();
|
||||
await expect(page.getByRole("button", { name: /Reload profiles|Profile neu laden/ })).toBeEnabled();
|
||||
return { writes, reads, errors, unexpectedWrites, raw: () => raw, revision: () => revision };
|
||||
}
|
||||
|
||||
for (const language of ["en", "de"] as const) {
|
||||
test(`real Mail settings never display inherited defaults as explicit saved credentials in ${language}`, async ({ page }) => {
|
||||
const fixture = await install(page, { language, requireCredential: true });
|
||||
const credential = page.getByRole("combobox", { name: "SMTP credential", exact: true });
|
||||
await expect(credential).toHaveValue("");
|
||||
await expect(credential.locator("option:checked")).toContainText(language === "en" ? "Use profile credentials" : "Profil-Zugangsdaten verwenden");
|
||||
await expect(credential.locator('option[value="credential-default"]')).toHaveCount(1);
|
||||
expect(fixture.writes).toHaveLength(0);
|
||||
await credential.selectOption("credential-default");
|
||||
await page.getByRole("button", { name: language === "en" ? "Migrate to selected Mail profile" : "Auf ausgewähltes Mail-Profil umstellen", exact: true }).click();
|
||||
await expect.poll(() => fixture.revision()).toBe(2);
|
||||
await expect(page.getByRole("button", { name: language === "en" ? "Migrate to selected Mail profile" : "Auf ausgewähltes Mail-Profil umstellen", exact: true })).toHaveCount(0);
|
||||
expect(fixture.writes).toHaveLength(1);
|
||||
expect(fixture.writes[0].migrate_legacy_mail_settings).toBe(true);
|
||||
expect(fixture.writes[0].campaign_json.server).toEqual({
|
||||
mail_profile_id: "profile-1", smtp_server_id: "smtp-1", smtp_credential_id: "credential-default"
|
||||
});
|
||||
expect(fixture.writes[0].campaign_json.attachments.zip).toEqual(zip);
|
||||
expect(fixture.writes[0].base_revision).toBe(1);
|
||||
expect(fixture.unexpectedWrites).toEqual([]);
|
||||
expect(fixture.errors).toEqual([]);
|
||||
await page.reload();
|
||||
await expect(credential).toHaveValue("credential-default");
|
||||
expect(fixture.writes).toHaveLength(1);
|
||||
});
|
||||
}
|
||||
|
||||
test("explicit-credential 422 preserves the real Mail draft and can be corrected then saved", async ({ page }) => {
|
||||
const fixture = await install(page, { requireCredential: true });
|
||||
await page.getByRole("button", { name: "Migrate to selected Mail profile", exact: true }).click();
|
||||
await expect(page.getByText(/effective SMTP credential policy requires an explicit credential selection/)).toBeVisible();
|
||||
expect(fixture.revision()).toBe(1);
|
||||
expect(fixture.writes).toHaveLength(1);
|
||||
await expect(page.getByRole("combobox", { name: "SMTP server", exact: true })).toHaveValue("smtp-1");
|
||||
await expect(page.getByRole("combobox", { name: "SMTP credential", exact: true })).toHaveValue("");
|
||||
await expect(page.getByRole("button", { name: "Migrate to selected Mail profile", exact: true })).toBeEnabled();
|
||||
await page.getByRole("combobox", { name: "SMTP credential", exact: true }).selectOption("credential-alternate");
|
||||
await page.getByRole("button", { name: "Save", exact: true }).click();
|
||||
await expect.poll(() => fixture.revision()).toBe(2);
|
||||
await expect(page.getByRole("button", { name: "Migrate to selected Mail profile", exact: true })).toHaveCount(0);
|
||||
expect(fixture.writes).toHaveLength(2);
|
||||
expect(fixture.writes[0].campaign_json.server.smtp_credential_id).toBeUndefined();
|
||||
expect(fixture.writes[1].campaign_json.server.smtp_credential_id).toBe("credential-alternate");
|
||||
expect(fixture.writes[1].campaign_json.server.smtp_server_id).toBe("smtp-1");
|
||||
expect(fixture.writes[1].campaign_json.attachments.zip).toEqual(zip);
|
||||
expect(fixture.errors).toEqual([]);
|
||||
expect(fixture.unexpectedWrites).toEqual([]);
|
||||
});
|
||||
|
||||
for (const credentialId of ["credential-missing", "credential-inactive"]) {
|
||||
test(`a stored ${credentialId} stays visibly unavailable until explicitly replaced`, async ({ page }) => {
|
||||
const fixture = await install(page, { selectedServer: {
|
||||
mail_profile_id: "profile-1", smtp_server_id: "smtp-1", smtp_credential_id: credentialId
|
||||
} });
|
||||
const credential = page.getByRole("combobox", { name: "SMTP credential", exact: true });
|
||||
await expect(credential).toHaveValue(credentialId);
|
||||
await expect(credential.locator("option:checked")).toHaveText("Selected credential is unavailable");
|
||||
await expect(credential.locator('option[value="credential-inactive"]')).toHaveCount(credentialId === "credential-inactive" ? 1 : 0);
|
||||
expect(fixture.writes).toHaveLength(0);
|
||||
await credential.selectOption("credential-default");
|
||||
await page.getByRole("button", { name: "Save", exact: true }).click();
|
||||
await expect.poll(() => fixture.revision()).toBe(2);
|
||||
expect(fixture.writes[0].campaign_json.server.smtp_credential_id).toBe("credential-default");
|
||||
expect(fixture.errors).toEqual([]);
|
||||
});
|
||||
}
|
||||
|
||||
for (const serverId of ["smtp-missing", "smtp-inactive"]) {
|
||||
test(`a stored ${serverId} cannot silently fall back to the default SMTP endpoint`, async ({ page }) => {
|
||||
const fixture = await install(page, { selectedServer: {
|
||||
mail_profile_id: "profile-1", smtp_server_id: serverId, smtp_credential_id: "credential-default"
|
||||
} });
|
||||
const server = page.getByRole("combobox", { name: "SMTP server", exact: true });
|
||||
await expect(server).toHaveValue(serverId);
|
||||
await expect(server.locator("option:checked")).toContainText("unavailable");
|
||||
await expect(page.getByRole("combobox", { name: "SMTP credential", exact: true })).toBeDisabled();
|
||||
expect(fixture.writes).toHaveLength(0);
|
||||
await server.selectOption("smtp-1");
|
||||
await page.getByRole("button", { name: "Save", exact: true }).click();
|
||||
await expect.poll(() => fixture.revision()).toBe(2);
|
||||
expect(fixture.writes[0].campaign_json.server).toEqual({
|
||||
mail_profile_id: "profile-1", smtp_server_id: "smtp-1", smtp_credential_id: "credential-default"
|
||||
});
|
||||
expect(fixture.errors).toEqual([]);
|
||||
});
|
||||
}
|
||||
|
||||
test("an unavailable profile remains identified and cannot be silently migrated", async ({ page }) => {
|
||||
const fixture = await install(page, { excludeProfile: true });
|
||||
await expect(page.getByRole("combobox", { name: "Profile", exact: true })).toHaveValue("profile-1");
|
||||
await expect(page.getByRole("button", { name: "Migrate to selected Mail profile", exact: true })).toBeDisabled();
|
||||
await expect(page.getByRole("combobox", { name: "SMTP server", exact: true })).toBeDisabled();
|
||||
expect(fixture.writes).toHaveLength(0);
|
||||
expect(fixture.reads.filter((url) => url.pathname === "/api/v1/mail/profiles").every((url) => url.searchParams.get("campaign_id") === "campaign-mail")).toBe(true);
|
||||
expect(fixture.unexpectedWrites).toEqual([]);
|
||||
});
|
||||
@@ -0,0 +1,119 @@
|
||||
import { expect, test, type Page } from "@playwright/test";
|
||||
|
||||
async function install(page: Page, language = "en", rejectSave = false) {
|
||||
let revision = 1;
|
||||
let raw: any = null;
|
||||
let rejected = false;
|
||||
const writes: any[] = [];
|
||||
const errors: string[] = [];
|
||||
page.on("pageerror", (error) => errors.push(error.message));
|
||||
const version = () => ({ id: "order-version", campaign_id: "order-campaign", version_number: 1,
|
||||
edit_revision: revision, strong_etag: `"order-version:${revision}"`, editor_state: {},
|
||||
workflow_state: "editing", current_flow: "manual", current_step: "recipients", is_complete: false,
|
||||
updated_at: "2026-09-07T10:00:00Z", raw_json: raw });
|
||||
await page.route((url) => url.pathname.startsWith("/api/"), async (route) => {
|
||||
if (route.request().method() === "POST" && route.request().url().endsWith("/autosave")) {
|
||||
const body = route.request().postDataJSON(); writes.push(body);
|
||||
if (rejectSave && !rejected) {
|
||||
rejected = true;
|
||||
return route.fulfill({ status: 422, json: { detail: "Fixture policy rejected this save" } });
|
||||
}
|
||||
raw = body.campaign_json; revision += 1;
|
||||
return route.fulfill({ json: version() });
|
||||
}
|
||||
if (route.request().method() === "GET") return route.fulfill({ json: version() });
|
||||
return route.abort();
|
||||
});
|
||||
await page.goto(`/?campaign-recipient-order&language=${language}`);
|
||||
await expect(page.getByTestId("recipient-order")).toContainText("alpha@example.test");
|
||||
return { writes, errors, raw: () => raw };
|
||||
}
|
||||
|
||||
async function moveLastToFirst(page: Page) {
|
||||
const dialog = page.getByRole("dialog");
|
||||
await dialog.getByRole("button", { name: "Move address up", exact: true }).nth(2).click();
|
||||
await dialog.getByRole("button", { name: "Move address up", exact: true }).nth(1).click();
|
||||
await expect(dialog.locator('input[type="email"]').first()).toHaveValue("zulu@example.test");
|
||||
}
|
||||
|
||||
for (const language of ["en", "de"]) {
|
||||
test(`individual recipient order survives dialog Save, reopen and actual campaign POST in ${language}`, async ({ page }) => {
|
||||
const fixture = await install(page, language);
|
||||
await page.getByRole("button", { name: "Edit individual addresses", exact: true }).click();
|
||||
await moveLastToFirst(page);
|
||||
await page.getByRole("dialog").getByRole("button", { name: language === "de" ? "Speichern" : "Save", exact: true }).click();
|
||||
await expect(page.getByTestId("primary-address")).toHaveText("zulu@example.test");
|
||||
await expect(page.getByTestId("recipient-dirty")).toHaveText("true");
|
||||
expect(fixture.writes).toHaveLength(0); // Dialog Save applies the page draft, not a hidden network mutation.
|
||||
await page.getByRole("button", { name: "Edit individual addresses", exact: true }).click();
|
||||
await expect(page.getByRole("dialog").locator('input[type="email"]').first()).toHaveValue("zulu@example.test");
|
||||
await page.getByRole("dialog").getByRole("button", { name: language === "de" ? "Abbrechen" : "Cancel", exact: true }).click();
|
||||
await page.getByRole("button", { name: "Save campaign", exact: true }).click();
|
||||
await expect(page.getByTestId("recipient-dirty")).toHaveText("false");
|
||||
expect(fixture.writes[0].campaign_json.entries.inline[0].to.map((item: any) => item.email)).toEqual([
|
||||
"zulu@example.test", "alpha@example.test", "beta@example.test"
|
||||
]);
|
||||
expect(fixture.raw().entries.inline[0].email).toBe("zulu@example.test");
|
||||
await page.getByRole("button", { name: "Edit individual addresses", exact: true }).click();
|
||||
await expect(page.getByRole("dialog").locator('input[type="email"]').first()).toHaveValue("zulu@example.test");
|
||||
expect(fixture.errors).toEqual([]);
|
||||
});
|
||||
}
|
||||
|
||||
test("global header addresses preserve their explicitly selected order", async ({ page }) => {
|
||||
const fixture = await install(page);
|
||||
await page.getByRole("button", { name: "Edit global addresses", exact: true }).click();
|
||||
await moveLastToFirst(page);
|
||||
await page.getByRole("dialog").getByRole("button", { name: "Save", exact: true }).click();
|
||||
await page.getByRole("button", { name: "Save campaign", exact: true }).click();
|
||||
await expect(page.getByTestId("recipient-dirty")).toHaveText("false");
|
||||
expect(fixture.raw().recipients.to.map((item: any) => item.email)).toEqual([
|
||||
"zulu@example.test", "alpha@example.test", "beta@example.test"
|
||||
]);
|
||||
await page.getByRole("button", { name: "Edit global addresses", exact: true }).click();
|
||||
await expect(page.getByRole("dialog").locator('input[type="email"]').first()).toHaveValue("zulu@example.test");
|
||||
});
|
||||
|
||||
test("paste deduplicates without alphabetically rearranging existing or newly added addresses", async ({ page }) => {
|
||||
const fixture = await install(page);
|
||||
await page.getByRole("button", { name: "Edit individual addresses", exact: true }).click();
|
||||
await moveLastToFirst(page);
|
||||
await page.getByRole("dialog").locator(".recipient-address-category").evaluate((section) => {
|
||||
const clipboard = new DataTransfer();
|
||||
clipboard.setData("text/plain", "Zulu <ZULU@example.test>; Omega <omega@example.test>; Charlie <charlie@example.test>");
|
||||
section.dispatchEvent(new ClipboardEvent("paste", { clipboardData: clipboard, bubbles: true, cancelable: true }));
|
||||
});
|
||||
await expect(page.getByRole("dialog").locator('input[type="email"]')).toHaveCount(5);
|
||||
await page.getByRole("dialog").getByRole("button", { name: "Save", exact: true }).click();
|
||||
await page.getByRole("button", { name: "Save campaign", exact: true }).click();
|
||||
await expect(page.getByTestId("recipient-dirty")).toHaveText("false");
|
||||
expect(fixture.raw().entries.inline[0].to.map((item: any) => item.email)).toEqual([
|
||||
"zulu@example.test", "alpha@example.test", "beta@example.test", "omega@example.test", "charlie@example.test"
|
||||
]);
|
||||
});
|
||||
|
||||
test("failed campaign save retains the reordered dialog result for explicit retry", async ({ page }) => {
|
||||
const fixture = await install(page, "en", true);
|
||||
await page.getByRole("button", { name: "Edit individual addresses", exact: true }).click();
|
||||
await moveLastToFirst(page);
|
||||
await page.getByRole("dialog").getByRole("button", { name: "Save", exact: true }).click();
|
||||
await page.getByRole("button", { name: "Save campaign", exact: true }).click();
|
||||
await expect(page.getByTestId("recipient-error")).toContainText("Fixture policy rejected");
|
||||
await expect(page.getByTestId("recipient-dirty")).toHaveText("true");
|
||||
await page.getByRole("button", { name: "Edit individual addresses", exact: true }).click();
|
||||
await expect(page.getByRole("dialog").locator('input[type="email"]').first()).toHaveValue("zulu@example.test");
|
||||
await page.getByRole("dialog").getByRole("button", { name: "Cancel", exact: true }).click();
|
||||
await page.getByRole("button", { name: "Save campaign", exact: true }).click();
|
||||
await expect(page.getByTestId("recipient-dirty")).toHaveText("false");
|
||||
expect(fixture.writes).toHaveLength(2);
|
||||
expect(fixture.writes[1].campaign_json.entries.inline[0].to).toEqual(fixture.writes[0].campaign_json.entries.inline[0].to);
|
||||
});
|
||||
|
||||
test("Cancel deliberately discards only the dialog's unconfirmed reorder", async ({ page }) => {
|
||||
await install(page);
|
||||
await page.getByRole("button", { name: "Edit individual addresses", exact: true }).click();
|
||||
await moveLastToFirst(page);
|
||||
await page.getByRole("dialog").getByRole("button", { name: "Cancel", exact: true }).click();
|
||||
await expect(page.getByTestId("recipient-dirty")).toHaveText("false");
|
||||
await expect(page.getByTestId("primary-address")).toHaveText("alpha@example.test");
|
||||
});
|
||||
@@ -0,0 +1,270 @@
|
||||
import { expect, test, type Page } from "@playwright/test";
|
||||
|
||||
async function install(page: Page, options: { language?: "en" | "de"; readOnly?: boolean; rejectFirst?: boolean; holdWrite?: Promise<void>; switchAccount?: boolean } = {}) {
|
||||
const errors: string[] = [];
|
||||
page.on("pageerror", error => errors.push(error.message));
|
||||
const writes: { path: string; payload: Record<string, any> }[] = [];
|
||||
let detailReads = 0;
|
||||
let holdNextRead: Promise<void> | undefined;
|
||||
let heldReads = 0;
|
||||
const statuses = ["failed_temporary", "failed_permanent", "outcome_unknown", "sending", "sending", "queued", "smtp_accepted", "skipped"];
|
||||
const jobs = statuses.map((status, index) => ({
|
||||
id: `job-${index + 1}`, campaign_version_id: "report-version", entry_id: `entry-${index + 1}`, entry_index: index + 1,
|
||||
recipient_email: `primary-${index + 1}@example.test`, subject: `Report message ${index + 1}`,
|
||||
resolved_recipients: { to: [{ email: `primary-${index + 1}@example.test` }, { name: "Additional person", email: `additional-${index + 1}@example.test` }],
|
||||
cc: [{ email: `copy-${index + 1}@example.test` }], bcc: [{ email: `blind-${index + 1}@example.test` }] },
|
||||
build_status: "built", validation_status: status === "skipped" ? "excluded" : "ready", queue_status: status === "sending" ? "claimed" : "draft",
|
||||
send_status: status, imap_status: status === "smtp_accepted" ? "appended" : index === 0 ? "pending" : index === 1 ? "appending" : index === 2 ? "outcome_unknown" : "not_requested", attempt_count: ["not_queued", "queued", "skipped"].includes(status) ? 0 : 1,
|
||||
postbox_attempt_count: 0, print_attempt_count: 0,
|
||||
attachments: [], recovery: { smtp: { eligible: index === 3, revision: `claim-revision-${index + 1}`, reason: index === 3 ? "recoverable" : "live_claim" }, imap: { eligible: false, revision: "imap-revision", reason: "not_active" } }
|
||||
}));
|
||||
const version = { id: "report-version", campaign_id: "report-campaign", version_number: 1, edit_revision: 1, strong_etag: '"report-version:1"',
|
||||
workflow_state: "partially_completed", locked_at: "2026-09-07T10:00:00Z", raw_json: {}, editor_state: {} };
|
||||
const summary = () => ({ cards: { jobs_total: jobs.length, sent: jobs.filter(job => job.send_status === "smtp_accepted").length, failed: jobs.filter(job => job.send_status.startsWith("failed")).length },
|
||||
delivery: { background_workers_enabled: false, celery_enabled: false }, status_counts: {
|
||||
send: Object.fromEntries([...new Set(jobs.map(job => job.send_status))].map(status => [status, jobs.filter(job => job.send_status === status).length])),
|
||||
imap: Object.fromEntries([...new Set(jobs.map(job => job.imap_status))].map(status => [status, jobs.filter(job => job.imap_status === status).length]))
|
||||
} });
|
||||
await page.route(url => url.pathname.startsWith("/api/"), async route => {
|
||||
const request = route.request(); const url = new URL(request.url());
|
||||
if (request.method() === "GET") {
|
||||
if (url.pathname.endsWith("/workspace/delta")) return route.fulfill({ json: {
|
||||
campaign: { id: "report-campaign", name: "Report fixture", current_version_id: "report-version", status: "partially_completed" },
|
||||
versions: [version], current_version: version, summary: summary(), deleted: [], full: true, has_more: false, watermark: "report-watermark"
|
||||
} });
|
||||
if (url.pathname.endsWith("/delivery-progress")) return route.fulfill({ json: {
|
||||
campaign_id: "report-campaign", version_id: "report-version", generated_at: "2026-09-07T10:00:00Z", total_jobs: jobs.length,
|
||||
smtp: { total: 7, processed: 4, accepted: summary().cards.sent, active: 2, pending: 1, failed: summary().cards.failed, outcome_unknown: 1, excluded: 1, paused: 0, cancelled: 0 },
|
||||
imap: { total: 1, processed: 1, appended: 1, active: 0, pending: 0, failed: 0, outcome_unknown: 0, excluded: 7 }, status_counts: {}
|
||||
} });
|
||||
if (url.pathname.endsWith("/jobs")) {
|
||||
const search = (url.searchParams.get("q") ?? url.searchParams.get("filter_recipient") ?? "").toLowerCase();
|
||||
const matchesStatus = (column: "send" | "imap", status: string) => {
|
||||
const filter = url.searchParams.get(`filter_${column}`);
|
||||
return !filter?.startsWith("list:") || JSON.parse(filter.slice(5)).includes(status);
|
||||
};
|
||||
const selected = structuredClone(jobs.filter(job => (!search || JSON.stringify([job.resolved_recipients, job.recipient_email, job.entry_id, job.subject]).toLowerCase().includes(search)) &&
|
||||
matchesStatus("send", job.send_status) && matchesStatus("imap", job.imap_status)));
|
||||
const hold = holdNextRead;
|
||||
if (hold) { holdNextRead = undefined; heldReads++; await hold; }
|
||||
return route.fulfill({ json: { jobs: selected, page: 1, page_size: 50, total: selected.length, total_unfiltered: jobs.length, pages: 1, counts: { send: {}, imap: {} }, filtered_counts: {} } });
|
||||
}
|
||||
if (/\/jobs\/job-\d+$/.test(url.pathname)) {
|
||||
detailReads++;
|
||||
return route.fulfill({ json: { job: jobs.find(job => url.pathname.endsWith(job.id)), attempts: { smtp: [], imap: [] } } });
|
||||
}
|
||||
return route.fulfill({ json: {} });
|
||||
}
|
||||
const payload = request.postDataJSON(); writes.push({ path: url.pathname, payload });
|
||||
if (options.holdWrite) await options.holdWrite;
|
||||
if (options.rejectFirst && writes.length === 1) return route.fulfill({ status: 409, json: { detail: "Fixture recovery changed. Reload evidence before retrying." } });
|
||||
if (/\/jobs\/(retry|send-unattempted)$/.test(url.pathname)) {
|
||||
expect(payload.run_inline).toBe(true); expect(payload.enqueue_celery).toBe(false);
|
||||
expect(payload.version_id).toBe("report-version");
|
||||
for (const id of payload.job_ids) {
|
||||
const job = jobs.find(row => row.id === id)!;
|
||||
expect(["failed_temporary", "failed_permanent", "not_queued", "queued"]).toContain(job.send_status);
|
||||
job.send_status = "smtp_accepted";
|
||||
}
|
||||
return route.fulfill({ json: { result: { selected_count: payload.job_ids.length, attempted_count: payload.job_ids.length,
|
||||
sent_count: payload.job_ids.length, failed_count: 0, outcome_unknown_count: 0, remaining_count: 0, run_inline: true } } });
|
||||
}
|
||||
const job = jobs.find(row => url.pathname.includes(`/jobs/${row.id}/`));
|
||||
if (url.pathname.endsWith("/recover-claim") && job) {
|
||||
expect(payload.expected_revision).toBe(job.recovery.smtp.revision); expect(payload.note.trim()).not.toBe("");
|
||||
job.send_status = "outcome_unknown"; job.recovery.smtp.eligible = false;
|
||||
return route.fulfill({ json: { result: { job_id: job.id } } });
|
||||
}
|
||||
if (url.pathname.endsWith("/resolve-outcome") && job) {
|
||||
expect(payload.note.trim()).not.toBe("");
|
||||
job.send_status = payload.decision === "smtp_accepted" ? "smtp_accepted" : "failed_temporary";
|
||||
return route.fulfill({ json: { result: { job_id: job.id } } });
|
||||
}
|
||||
return route.abort();
|
||||
});
|
||||
await page.goto(`/?campaign-report&language=${options.language ?? "en"}${options.readOnly ? "&read-only" : ""}${options.switchAccount ? "&switch-account" : ""}`);
|
||||
const table = page.getByRole("table", { name: "campaign-report-jobs-v2-report-campaign", exact: true });
|
||||
await expect(table.getByText("Report message 1", { exact: true })).toBeVisible();
|
||||
return { table, jobs, writes, errors, holdNextJobsRead(hold: Promise<void>) { holdNextRead = hold; }, get heldReads() { return heldReads; }, get detailReads() { return detailReads; } };
|
||||
}
|
||||
|
||||
for (const language of ["en", "de"] as const) test(`${language}: Report shows every To/Cc/Bcc recipient and searches without per-row details`, async ({ page }) => {
|
||||
const fixture = await install(page, { language });
|
||||
await expect(fixture.table.getByText("Additional person <additional-1@example.test>", { exact: false })).toBeVisible();
|
||||
await expect(fixture.table.getByText("copy-1@example.test", { exact: true })).toBeVisible();
|
||||
await expect(fixture.table.getByText("blind-1@example.test", { exact: true })).toBeVisible();
|
||||
await expect(fixture.table.getByText(language === "de" ? "An:" : "To:", { exact: true }).first()).toBeVisible();
|
||||
expect(fixture.detailReads).toBe(0);
|
||||
await page.getByRole("textbox").first().fill("blind-2@example.test");
|
||||
await expect(fixture.table.getByText("Report message 2", { exact: true })).toBeVisible();
|
||||
await expect(fixture.table.getByText("Report message 1", { exact: true })).toHaveCount(0);
|
||||
expect(fixture.detailReads).toBe(0); expect(fixture.writes).toHaveLength(0); expect(fixture.errors).toEqual([]);
|
||||
});
|
||||
|
||||
test("workerless Report retry executes one canonical request and displays real acceptance", async ({ page }) => {
|
||||
const fixture = await install(page);
|
||||
await expect(page.getByRole("button", { name: "Queue temporary failures for workers", exact: true })).toBeDisabled();
|
||||
await fixture.table.getByRole("button", { name: "Retry now", exact: true }).first().click();
|
||||
await expect.poll(() => fixture.writes.length).toBe(1);
|
||||
expect(fixture.writes[0].path).toMatch(/\/jobs\/retry$/);
|
||||
expect(fixture.writes[0].payload.job_ids).toEqual(["job-1"]);
|
||||
await expect(page.getByRole("dialog").getByRole("button", { name: /Close|Schließen/, exact: true }).last()).toBeEnabled();
|
||||
await page.getByRole("dialog").getByRole("button", { name: /Close|Schließen/, exact: true }).last().click();
|
||||
await expect(page.getByText(/Delivery request finished: 1 attempted, 1 accepted, 0 failed/)).toBeVisible();
|
||||
await page.reload();
|
||||
await expect(fixture.table.getByText("Report message 1", { exact: true })).toBeVisible();
|
||||
expect(fixture.jobs[0].send_status).toBe("smtp_accepted"); expect(fixture.writes).toHaveLength(1); expect(fixture.errors).toEqual([]);
|
||||
});
|
||||
|
||||
test("page retry and unattempted continuation exclude accepted, active and uncertain messages", async ({ page }) => {
|
||||
const fixture = await install(page);
|
||||
expect(fixture.jobs[5].send_status).toBe("queued");
|
||||
await page.getByRole("button", { name: "Retry failed messages on this page now (2)", exact: true }).click();
|
||||
await expect.poll(() => fixture.writes.length).toBe(1);
|
||||
expect(fixture.writes[0].payload.job_ids).toEqual(["job-1", "job-2"]);
|
||||
expect(fixture.writes[0].payload.include_permanent).toBe(true);
|
||||
await page.getByRole("dialog").getByRole("button", { name: /Close|Schließen/, exact: true }).last().click();
|
||||
await page.getByRole("button", { name: "Send unattempted messages on this page now (1)", exact: true }).click();
|
||||
await expect.poll(() => fixture.writes.length).toBe(2);
|
||||
expect(fixture.writes[1].path).toMatch(/\/jobs\/send-unattempted$/);
|
||||
expect(fixture.writes[1].payload.job_ids).toEqual(["job-6"]);
|
||||
expect(fixture.errors).toEqual([]);
|
||||
});
|
||||
|
||||
test("reconciliation requires evidence, retains failed input, and retries only explicitly", async ({ page }) => {
|
||||
const fixture = await install(page, { rejectFirst: true });
|
||||
await fixture.table.getByRole("button", { name: /^(Not sent|Nicht gesendet)$/ }).and(page.locator(":enabled")).click();
|
||||
const dialog = page.getByRole("dialog");
|
||||
await expect(dialog.getByRole("button", { name: "Record message as not sent", exact: true })).toBeDisabled();
|
||||
await dialog.getByRole("textbox", { name: /^Evidence note/ }).fill("SMTP logs show no acceptance for this message ID.");
|
||||
await dialog.getByRole("button", { name: "Record message as not sent", exact: true }).click();
|
||||
await expect(dialog.getByText("Fixture recovery changed. Reload evidence before retrying.", { exact: false })).toBeVisible();
|
||||
await expect(dialog.getByRole("textbox")).toHaveValue("SMTP logs show no acceptance for this message ID.");
|
||||
expect(fixture.writes).toHaveLength(1);
|
||||
await dialog.getByRole("button", { name: "Record message as not sent", exact: true }).click();
|
||||
await expect(dialog).toHaveCount(0);
|
||||
expect(fixture.writes[1].payload.note).toBe("SMTP logs show no acceptance for this message ID.");
|
||||
expect(fixture.jobs[2].send_status).toBe("failed_temporary");
|
||||
expect(fixture.writes).toHaveLength(2); expect(fixture.errors).toEqual([]);
|
||||
});
|
||||
|
||||
test("only a server-proven stale claim can be recovered, without implicit send or reconciliation", async ({ page }) => {
|
||||
const fixture = await install(page);
|
||||
await expect(fixture.table.getByText(/Processing is still active or its lease has not expired/)).toBeVisible();
|
||||
await expect(fixture.table.getByRole("button", { name: "Recover interrupted SMTP processing", exact: true }).and(page.locator(":enabled"))).toHaveCount(1);
|
||||
await fixture.table.getByRole("button", { name: "Recover interrupted SMTP processing", exact: true }).and(page.locator(":enabled")).click();
|
||||
const dialog = page.getByRole("dialog");
|
||||
await dialog.getByRole("textbox").fill("Previous process stopped; checked its expired lease and server logs.");
|
||||
await dialog.getByRole("button", { name: "Mark outcome for investigation", exact: true }).click();
|
||||
await expect(dialog).toHaveCount(0);
|
||||
expect(fixture.writes).toHaveLength(1); expect(fixture.writes[0].path).toContain("/jobs/job-4/recover-claim");
|
||||
expect(fixture.writes[0].payload.expected_revision).toBe("claim-revision-4");
|
||||
expect(fixture.jobs[3].send_status).toBe("outcome_unknown"); expect(fixture.jobs[4].send_status).toBe("sending");
|
||||
expect(fixture.errors).toEqual([]);
|
||||
});
|
||||
|
||||
test("pending recovery cannot duplicate or dismiss, and read-only Report cannot mutate", async ({ page }) => {
|
||||
let release!: () => void;
|
||||
const held = new Promise<void>(resolve => { release = resolve; });
|
||||
const fixture = await install(page, { holdWrite: held });
|
||||
await fixture.table.getByRole("button", { name: /^(Accepted|Angenommen)$/ }).and(page.locator(":enabled")).click();
|
||||
const dialog = page.getByRole("dialog");
|
||||
await dialog.getByRole("textbox").fill("Verified SMTP acceptance in the server log.");
|
||||
await dialog.getByRole("button", { name: "Record SMTP acceptance", exact: true }).dblclick();
|
||||
await expect.poll(() => fixture.writes.length).toBe(1);
|
||||
await page.keyboard.press("Escape");
|
||||
await expect(dialog).toBeVisible(); await expect(dialog.getByRole("textbox")).toBeDisabled();
|
||||
release(); await expect(dialog).toHaveCount(0);
|
||||
await page.goto("/?campaign-report&language=en&read-only");
|
||||
await expect(fixture.table.getByRole("button", { name: "Retry now", exact: true }).first()).toBeDisabled();
|
||||
await expect(fixture.table.getByRole("button", { name: "Recover interrupted SMTP processing", exact: true }).first()).toBeDisabled();
|
||||
expect(fixture.writes).toHaveLength(1); expect(fixture.errors).toEqual([]);
|
||||
});
|
||||
|
||||
test("queued remainder can continue by row, but previous SMTP, Postbox or Print attempts cannot", async ({ page }) => {
|
||||
const fixture = await install(page);
|
||||
const continuations = fixture.table.getByRole("button", { name: "Send unattempted message now", exact: true }).and(page.locator(":enabled"));
|
||||
for (const field of ["attempt_count", "postbox_attempt_count", "print_attempt_count"] as const) {
|
||||
fixture.jobs[5][field] = 1;
|
||||
await page.getByRole("button", { name: "Reload", exact: true }).click();
|
||||
await expect(continuations).toHaveCount(0);
|
||||
await expect(page.getByRole("button", { name: "Send unattempted messages on this page now (0)", exact: true })).toBeDisabled();
|
||||
fixture.jobs[5][field] = 0;
|
||||
}
|
||||
await page.getByRole("button", { name: "Reload", exact: true }).click();
|
||||
await expect(continuations).toHaveCount(1);
|
||||
await continuations.click();
|
||||
await expect.poll(() => fixture.writes.length).toBe(1);
|
||||
expect(fixture.writes[0].payload.job_ids).toEqual(["job-6"]);
|
||||
expect(fixture.jobs[3].send_status).toBe("sending");
|
||||
expect(fixture.jobs[2].send_status).toBe("outcome_unknown");
|
||||
expect(fixture.errors).toEqual([]);
|
||||
});
|
||||
|
||||
test("active, queued and incomplete IMAP counts drill down to the exact current states", async ({ page }) => {
|
||||
const fixture = await install(page);
|
||||
for (const [label, count, ids] of [
|
||||
["Sending", 2, [4, 5]], ["Queued", 1, [6]], ["Pending", 1, [1]],
|
||||
["Copying to Sent", 1, [2]], ["Outcome uncertain", 1, [3]]
|
||||
] as const) {
|
||||
const shortcut = page.locator("dt").filter({ hasText: new RegExp(`^${label}$`) }).locator("..").getByRole("button");
|
||||
await expect(shortcut).toHaveText(String(count));
|
||||
await shortcut.click();
|
||||
await expect(shortcut).toHaveAttribute("aria-pressed", "true");
|
||||
await expect(fixture.table.getByText(/^Report message \d+$/)).toHaveCount(ids.length);
|
||||
for (const id of ids) await expect(fixture.table.getByText(`Report message ${id}`, { exact: true })).toBeVisible();
|
||||
}
|
||||
expect(fixture.writes).toHaveLength(0); expect(fixture.errors).toEqual([]);
|
||||
});
|
||||
|
||||
test("old report reads cannot restore rows after switching account context", async ({ page }) => {
|
||||
let release!: () => void;
|
||||
const held = new Promise<void>(resolve => { release = resolve; });
|
||||
const fixture = await install(page, { switchAccount: true });
|
||||
fixture.holdNextJobsRead(held);
|
||||
await page.getByRole("button", { name: "Reload", exact: true }).click();
|
||||
await expect.poll(() => fixture.heldReads).toBe(1);
|
||||
fixture.jobs[0].subject = "New account message";
|
||||
await page.getByRole("button", { name: "Switch fixture account" }).click();
|
||||
await expect(fixture.table.getByText("New account message", { exact: true })).toBeVisible();
|
||||
const oldResponse = page.waitForResponse(response => new URL(response.url()).pathname.endsWith("/jobs"));
|
||||
release();
|
||||
await oldResponse;
|
||||
await page.evaluate(() => new Promise(resolve => requestAnimationFrame(() => requestAnimationFrame(resolve))));
|
||||
await expect(fixture.table.getByText("Report message 1", { exact: true })).toHaveCount(0);
|
||||
await expect(fixture.table.getByText("New account message", { exact: true })).toBeVisible();
|
||||
expect(fixture.writes).toHaveLength(0); expect(fixture.errors).toEqual([]);
|
||||
});
|
||||
|
||||
test("an old recovery acknowledgment cannot reopen progress in another account context", async ({ page }) => {
|
||||
let release!: () => void;
|
||||
const held = new Promise<void>(resolve => { release = resolve; });
|
||||
const fixture = await install(page, { switchAccount: true, holdWrite: held });
|
||||
await fixture.table.getByRole("button", { name: "Retry now", exact: true }).first().click();
|
||||
await expect.poll(() => fixture.writes.length).toBe(1);
|
||||
await expect(page.getByRole("dialog")).toBeVisible();
|
||||
// Simulate an externally changed authentication provider while the request is pending.
|
||||
await page.getByRole("button", { name: "Switch fixture account" }).evaluate(button => (button as HTMLButtonElement).click());
|
||||
await expect(page.getByRole("dialog")).toHaveCount(0);
|
||||
const oldResponse = page.waitForResponse(response => new URL(response.url()).pathname.endsWith("/jobs/retry"));
|
||||
release();
|
||||
await oldResponse;
|
||||
await page.evaluate(() => new Promise(resolve => requestAnimationFrame(() => requestAnimationFrame(resolve))));
|
||||
await expect.poll(() => fixture.jobs[0].send_status).toBe("smtp_accepted");
|
||||
await expect(page.getByRole("dialog")).toHaveCount(0);
|
||||
await expect(page.getByText(/Delivery request finished:/)).toHaveCount(0);
|
||||
expect(fixture.writes).toHaveLength(1); expect(fixture.errors).toEqual([]);
|
||||
});
|
||||
|
||||
test("long recipient names and addresses wrap without clipping their contents", async ({ page }) => {
|
||||
const fixture = await install(page);
|
||||
const email = `${"long-address-segment-".repeat(5)}@example.test`;
|
||||
fixture.jobs[0].resolved_recipients.bcc = [{ email }];
|
||||
await page.getByRole("button", { name: "Reload", exact: true }).click();
|
||||
const cell = fixture.table.locator(".campaign-report-recipient-cell").filter({ hasText: email });
|
||||
await expect(cell.getByText(email, { exact: true })).toBeVisible();
|
||||
await expect.poll(() => cell.evaluate(node => ({ overflow: node.scrollWidth - node.clientWidth, height: node.getBoundingClientRect().height })))
|
||||
.toMatchObject({ overflow: 0 });
|
||||
expect(await cell.getByText(email, { exact: true }).evaluate(node => getComputedStyle(node).overflowWrap)).toBe("anywhere");
|
||||
expect(fixture.errors).toEqual([]);
|
||||
});
|
||||
@@ -0,0 +1,55 @@
|
||||
import { expect, test } from "@playwright/test";
|
||||
|
||||
for (const language of ["en", "de"]) {
|
||||
test(`${language}: validation cause/outcome groups and repeated files are fully paginated`, async ({ page }) => {
|
||||
const errors: string[] = [];
|
||||
page.on("pageerror", error => errors.push(error.message));
|
||||
await page.goto(`/?campaign-review-details&language=${language}`);
|
||||
const validation = page.getByRole("region", { name: "Validation details fixture" });
|
||||
const repeated = page.getByRole("region", { name: "Repeated files fixture" });
|
||||
const next = language === "en" ? "Next page" : "Nächste Seite";
|
||||
const previous = language === "en" ? "Previous page" : "Vorherige Seite";
|
||||
const details = language === "en" ? "Technical details" : "Technische Details";
|
||||
await expect(validation.locator("[data-validation-issue-group]")).toHaveCount(10);
|
||||
const first = validation.locator("[data-validation-issue-group]").first();
|
||||
await expect(first.getByText("Missing attachment for recipient 1.", { exact: true })).toBeVisible();
|
||||
await expect(first.getByText("Policy excludes recipient 1 without attachments.", { exact: true })).toBeVisible();
|
||||
await first.getByText(details, { exact: true }).click();
|
||||
await expect(first.getByText(/\/entries\/recipient-1\/attachments\/0/)).toBeVisible();
|
||||
await expect(first.getByText(/missing_attachment_coverage/)).toBeVisible();
|
||||
await validation.getByRole("button", { name: next, exact: true }).click();
|
||||
await expect(validation.locator("[data-validation-issue-group]")).toHaveCount(2);
|
||||
await expect(validation.getByText("Missing attachment for recipient 12.", { exact: true })).toBeVisible();
|
||||
await expect(validation.getByRole("button", { name: next, exact: true })).toBeDisabled();
|
||||
await validation.getByRole("button", { name: previous, exact: true }).click();
|
||||
await expect(validation.getByText("Missing attachment for recipient 1.", { exact: true })).toBeVisible();
|
||||
await validation.getByRole("combobox").selectOption("25");
|
||||
await expect(validation.locator("[data-validation-issue-group]")).toHaveCount(12);
|
||||
await expect(repeated.getByText(/repeated-file-12\.pdf/)).not.toBeVisible();
|
||||
await repeated.getByRole("button", { name: next, exact: true }).click();
|
||||
await expect(repeated.getByText(/repeated-file-12\.pdf/)).toBeVisible();
|
||||
await repeated.getByRole("button", { name: previous, exact: true }).click();
|
||||
await expect(repeated.getByText(/repeated-file-1\.pdf/)).toBeVisible();
|
||||
await expect(repeated.getByText(/repeated-file-12\.pdf/)).not.toBeVisible();
|
||||
expect(errors).toEqual([]);
|
||||
});
|
||||
}
|
||||
|
||||
test("detail lists clamp their last page when refreshed results shrink", async ({ page }) => {
|
||||
await page.goto("/?campaign-review-details&language=en");
|
||||
const validation = page.getByRole("region", { name: "Validation details fixture" });
|
||||
const repeated = page.getByRole("region", { name: "Repeated files fixture" });
|
||||
await validation.getByRole("button", { name: "Next page", exact: true }).click();
|
||||
await repeated.getByRole("button", { name: "Next page", exact: true }).click();
|
||||
await expect(validation.getByText("Missing attachment for recipient 12.", { exact: true })).toBeVisible();
|
||||
await expect(repeated.getByText(/repeated-file-12\.pdf/)).toBeVisible();
|
||||
await page.getByRole("button", { name: "Reduce fixture details", exact: true }).click();
|
||||
await expect(validation.locator("[data-validation-issue-group]")).toHaveCount(2);
|
||||
await expect(validation.getByText("Missing attachment for recipient 1.", { exact: true })).toBeVisible();
|
||||
await expect(repeated.getByText(/repeated-file-1\.pdf/)).toBeVisible();
|
||||
await expect(repeated.getByText(/repeated-file-12\.pdf/)).not.toBeVisible();
|
||||
for (const region of [validation, repeated]) {
|
||||
await expect(region.getByRole("button", { name: "Previous page", exact: true })).toBeDisabled();
|
||||
await expect(region.getByRole("button", { name: "Next page", exact: true })).toBeDisabled();
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,321 @@
|
||||
import { expect, test, type Page } from "@playwright/test";
|
||||
|
||||
async function install(page: Page, options: { rejectFirst?: boolean; conflictFirst?: boolean; stateMatrix?: boolean; holdSave?: Promise<void>; holdFirstJobs?: Promise<void>; rejectFirstJobs?: boolean; language?: "en" | "de"; readOnly?: boolean } = {}) {
|
||||
const errors: string[] = [];
|
||||
page.on("pageerror", error => { errors.push(error.message); console.error("Review fixture:", error.message); });
|
||||
const writes: Record<string, any>[] = [];
|
||||
const reads: string[] = [];
|
||||
let revision = 1;
|
||||
let complete = false;
|
||||
let buildGeneration = 1;
|
||||
let selectedVersionId = "review-version";
|
||||
let jobRequests = 0;
|
||||
const builds: Record<string, unknown>[] = [];
|
||||
const publicBuildToken = () => selectedVersionId === "replacement-version" ? "replacement-build-reference"
|
||||
: buildGeneration === 1 ? "opaque-build-reference" : `rebuilt-reference-${buildGeneration}`;
|
||||
const subjectFor = (subject: string) => selectedVersionId === "replacement-version" ? `Replacement ${subject}`
|
||||
: buildGeneration === 1 ? subject : `Rebuilt ${subject}`;
|
||||
const decisions = new Map<string, Record<string, unknown>>();
|
||||
const statuses = options.stateMatrix ? ["needs_review", "ready", "excluded", "warning", "blocked", "needs_review"] : ["needs_review", "needs_review", "excluded"];
|
||||
if (options.stateMatrix) decisions.set("job-6", { job_id: "job-6", decision: "accept", reason: "Already reviewed this specific exception." });
|
||||
const jobs = statuses.map((status, offset) => {
|
||||
const index = offset + 1;
|
||||
return { id: `job-${index}`, entry_id: `entry-${index}`, review_key: `entry-${index}`,
|
||||
entry_index: index, recipient_email: `recipient-${index}@example.test`, subject: `Message ${index}`,
|
||||
build_status: "built", validation_status: status, send_status: "not_queued",
|
||||
resolved_recipients: { to: [{ email: `recipient-${index}@example.test` }] }, attachments: [],
|
||||
issues: status === "ready" ? [] : [{ code: "missing_optional_attachment", behavior: status === "excluded" ? "drop" : status === "warning" ? "warn" : status === "blocked" ? "block" : "ask", severity: status === "blocked" ? "error" : "warning", source: "attachments", message: "Expected attachment was not found" }],
|
||||
review_decision: { eligible: status === "needs_review", category_key: "attachment-group", reason_required: true, issue_codes: ["missing_optional_attachment"] }
|
||||
}; });
|
||||
const version = () => ({ id: selectedVersionId, campaign_id: "review-campaign", version_number: 1,
|
||||
edit_revision: revision, strong_etag: `"${selectedVersionId}:${revision}"`, review_build_token: publicBuildToken(),
|
||||
locked_at: "2026-09-07T09:00:00Z",
|
||||
current_flow: "manual", current_step: "review", workflow_state: "built", is_complete: false,
|
||||
updated_at: "2026-09-07T10:00:00Z", build_summary: { built_count: 2, needs_review_count: 2 }, validation_summary: { ok: true, warning_count: 14, issues: [] },
|
||||
raw_json: { campaign: { name: "Review fixture" }, template: { subject: "Review", text: "Fixture message", html: "<p>Fixture message</p>" },
|
||||
server: {}, entries: { inline: jobs.map(row => ({ id: row.entry_id, email: row.recipient_email, active: true })) } },
|
||||
editor_state: { review_send: { review_build_token: publicBuildToken(), inspection_complete: complete,
|
||||
reviewed_message_keys: [...decisions.keys()].map(id => `entry-${id.split("-")[1]}`), issue_decisions: [...decisions.values()] } }
|
||||
});
|
||||
const jobResponse = () => ({ jobs: jobs.map(row => ({ ...row, subject: subjectFor(row.subject), reviewed: decisions.has(row.id) })), page: 1, page_size: 200,
|
||||
total: jobs.length, total_unfiltered: jobs.length, pages: 1, cursor: null, next_cursor: null, counts: {}, filtered_counts: {},
|
||||
review: { blocking_count: options.stateMatrix ? 1 : 0, required_count: 2, bulk_acceptable_count: options.stateMatrix ? 1 : 0, reviewed_required_count: decisions.size, inspection_complete: complete } });
|
||||
await page.route((url) => url.pathname.startsWith("/api/"), async route => {
|
||||
const request = route.request(); const url = new URL(request.url());
|
||||
if (request.method() === "GET") {
|
||||
reads.push(url.pathname);
|
||||
if (url.pathname.endsWith("/workspace/delta")) {
|
||||
selectedVersionId = url.searchParams.get("version_id") ?? "review-version";
|
||||
return route.fulfill({ json: {
|
||||
campaign: { id: "review-campaign", name: "Review fixture", current_version_id: selectedVersionId, status: "draft" },
|
||||
versions: [version()], current_version: version(), summary: { cards: { jobs_total: jobs.length, sent: 0, failed: 0 }, status_counts: {}, attachments: { missing_configs: 14 } },
|
||||
deleted: [], full: true, has_more: false, watermark: `w-${revision}`
|
||||
} }); }
|
||||
if (url.pathname.endsWith("/jobs")) {
|
||||
jobRequests++;
|
||||
const snapshot = jobResponse();
|
||||
if (jobRequests === 1 && options.holdFirstJobs) {
|
||||
// A stale response advertising another page must not start that
|
||||
// follow-up request after the selected build has changed.
|
||||
snapshot.pages = 2;
|
||||
await options.holdFirstJobs;
|
||||
}
|
||||
if (jobRequests === 1 && options.rejectFirstJobs) return route.fulfill({ status: 503, json: { detail: "Fixture initial review load failed" } });
|
||||
return route.fulfill({ json: snapshot });
|
||||
}
|
||||
if (/\/jobs\/job-/.test(url.pathname)) return route.fulfill({ json: { job: jobs.find(row => url.pathname.endsWith(row.id)), attempts: [] } });
|
||||
if (url.pathname.endsWith(`/versions/${selectedVersionId}`)) return route.fulfill({ json: version() });
|
||||
return route.fulfill({ json: {} });
|
||||
}
|
||||
if (url.pathname.endsWith("/attachments/preview")) return route.fulfill({ json: {
|
||||
campaign_id: "review-campaign", version_id: "review-version", shared_file_count: 0,
|
||||
rules: [], linkable_files: [], unused_shared_files: []
|
||||
} });
|
||||
if (request.method() === "POST" && url.pathname.endsWith("/review-state")) {
|
||||
const body = request.postDataJSON(); writes.push(body);
|
||||
if (options.holdSave) await options.holdSave;
|
||||
if (options.rejectFirst && writes.length === 1) return route.fulfill({ status: 503, json: { detail: "Fixture decision save unavailable" } });
|
||||
if (options.conflictFirst && writes.length === 1) {
|
||||
decisions.set("job-2", { job_id: "job-2", decision: "accept", reason: "Another reviewer checked this document.", actor_user_id: "other-reviewer" });
|
||||
revision++;
|
||||
return route.fulfill({ status: 409, json: { detail: "Another reviewer saved a decision. Inspect the refreshed progress and retry explicitly." } });
|
||||
}
|
||||
expect(body.merge_progress).toBe(true);
|
||||
expect(body.build_token).toBe(publicBuildToken());
|
||||
expect(body.base_revision).toBe(revision);
|
||||
for (const decision of body.issue_decisions) decisions.set(decision.job_id, decision);
|
||||
revision++; complete = body.inspection_complete;
|
||||
return route.fulfill({ json: version() });
|
||||
}
|
||||
if (request.method() === "POST" && url.pathname.endsWith("/build")) {
|
||||
builds.push(request.postDataJSON());
|
||||
buildGeneration++; revision++; complete = false; decisions.clear();
|
||||
return route.fulfill({ json: { built_count: 2, build_token: publicBuildToken() } });
|
||||
}
|
||||
return route.abort();
|
||||
});
|
||||
await page.goto(`/?campaign-review&language=${options.language ?? "en"}${options.readOnly ? "&read-only" : ""}${options.holdFirstJobs ? "&race" : ""}`);
|
||||
if (options.holdFirstJobs || options.rejectFirstJobs) await expect.poll(() => jobRequests).toBe(1);
|
||||
else await expect(page.getByText("Message 1", { exact: true }).first()).toBeVisible();
|
||||
return { writes, reads, errors, decisions, builds, get jobRequests() { return jobRequests; } };
|
||||
}
|
||||
|
||||
async function openMessage(page: Page, index = 1) {
|
||||
const table = page.getByRole("table", { name: "campaign-review-campaign-workflow-built-messages", exact: true });
|
||||
await expect(table.getByText(`Message ${index}`, { exact: true })).toBeVisible();
|
||||
await table.getByRole("button", { name: /^(Review|Prüfung)$/ }).nth(index - 1).click();
|
||||
await expect(page.getByRole("dialog")).toBeVisible();
|
||||
return page.getByRole("dialog");
|
||||
}
|
||||
|
||||
test("each acceptance persists immediately and advances, surviving reload before whole-review completion", async ({ page }) => {
|
||||
const fixture = await install(page);
|
||||
const dialog = await openMessage(page);
|
||||
const readsBefore = fixture.reads.length;
|
||||
await dialog.getByRole("textbox", { name: /^Why is this acceptable\?/ }).fill("Recipient already received the document.");
|
||||
expect(fixture.reads.length).toBe(readsBefore);
|
||||
expect(fixture.writes).toHaveLength(0);
|
||||
await dialog.getByRole("button", { name: "Accept and continue", exact: true }).click();
|
||||
await expect(dialog.getByText("recipient-2@example.test", { exact: true }).first()).toBeVisible();
|
||||
expect(fixture.writes).toHaveLength(1);
|
||||
expect(fixture.writes[0].inspection_complete).toBe(false);
|
||||
expect(fixture.writes[0].issue_decisions[0].reason).toBe("Recipient already received the document.");
|
||||
expect(fixture.reads.length).toBe(readsBefore);
|
||||
await page.reload();
|
||||
await openMessage(page);
|
||||
await expect(page.getByRole("dialog").getByText("Review decision saved. You can leave and continue later.")).toBeVisible();
|
||||
expect(fixture.writes).toHaveLength(1);
|
||||
expect(fixture.errors).toEqual([]);
|
||||
});
|
||||
|
||||
test("failed review save keeps the reason and current message until explicit retry succeeds", async ({ page }) => {
|
||||
const fixture = await install(page, { rejectFirst: true });
|
||||
const dialog = await openMessage(page);
|
||||
await dialog.getByRole("textbox", { name: /^Why is this acceptable\?/ }).fill("Retain this reason.");
|
||||
await dialog.getByRole("button", { name: "Accept and continue", exact: true }).click();
|
||||
await expect(dialog.getByText(/Fixture decision save unavailable/)).toBeVisible();
|
||||
await expect(dialog.getByRole("textbox", { name: /^Why is this acceptable\?/ })).toHaveValue("Retain this reason.");
|
||||
expect(fixture.decisions.size).toBe(0);
|
||||
await dialog.getByRole("button", { name: "Accept and continue", exact: true }).click();
|
||||
await expect(dialog.getByText("recipient-2@example.test", { exact: true }).first()).toBeVisible();
|
||||
expect(fixture.writes).toHaveLength(2);
|
||||
expect(fixture.errors).toEqual([]);
|
||||
});
|
||||
|
||||
test("pending individual acceptance cannot duplicate, dismiss, or navigate before acknowledgement", async ({ page }) => {
|
||||
let acknowledge!: () => void;
|
||||
const fixture = await install(page, { holdSave: new Promise<void>((resolve) => { acknowledge = resolve; }) });
|
||||
const dialog = await openMessage(page);
|
||||
await dialog.getByRole("textbox", { name: /^Why is this acceptable\?/ }).fill("Confirmed exception, waiting for durable save.");
|
||||
await dialog.getByRole("button", { name: "Accept and continue", exact: true }).evaluate((button: HTMLButtonElement) => {
|
||||
button.click(); button.click();
|
||||
});
|
||||
await expect.poll(() => fixture.writes.length).toBe(1);
|
||||
for (const navigation of await dialog.locator(".template-preview-nav button").all()) await expect(navigation).toBeDisabled();
|
||||
for (const close of await dialog.getByRole("button", { name: /^(Close|Schließen)$/ }).all()) await expect(close).toBeDisabled();
|
||||
await page.keyboard.press("Escape");
|
||||
await expect(dialog).toBeVisible();
|
||||
await expect(dialog.getByText("recipient-1@example.test", { exact: true }).first()).toBeVisible();
|
||||
acknowledge();
|
||||
await expect(dialog.getByText("recipient-2@example.test", { exact: true }).first()).toBeVisible();
|
||||
expect(fixture.writes).toHaveLength(1);
|
||||
expect(fixture.errors).toEqual([]);
|
||||
});
|
||||
|
||||
test("German individual review records its reason and skips intentional exclusions", async ({ page }) => {
|
||||
const fixture = await install(page, { language: "de" });
|
||||
let dialog = await openMessage(page);
|
||||
await dialog.getByRole("textbox", { name: /^Warum ist das in Ordnung\?/ }).fill("Das Dokument liegt dem Empfänger bereits vor.");
|
||||
await dialog.getByRole("button", { name: "Akzeptieren und weiter", exact: true }).click();
|
||||
await expect(dialog.getByText("recipient-2@example.test", { exact: true }).first()).toBeVisible();
|
||||
await dialog.getByRole("textbox", { name: /^Warum ist das in Ordnung\?/ }).fill("Auch dieser Empfänger benötigt keinen weiteren Anhang.");
|
||||
await dialog.getByRole("button", { name: "Akzeptieren und weiter", exact: true }).click();
|
||||
await expect(dialog).not.toBeVisible();
|
||||
expect(fixture.writes).toHaveLength(2);
|
||||
expect(fixture.decisions.has("job-3")).toBe(false);
|
||||
await page.reload();
|
||||
dialog = await openMessage(page);
|
||||
await expect(dialog.getByText("Prüfentscheidung gespeichert. Sie können die Prüfung später fortsetzen.")).toBeVisible();
|
||||
expect(fixture.errors).toEqual([]);
|
||||
});
|
||||
|
||||
test("real review grouped acceptance persists only eligible messages with exact category guard", async ({ page }) => {
|
||||
const fixture = await install(page);
|
||||
await expect(page.getByText(/14 validation warning.*need review/)).toHaveCount(1);
|
||||
await page.getByRole("button", { name: "Accept similar review conditions", exact: true }).click();
|
||||
const dialog = page.getByRole("dialog");
|
||||
await expect(dialog.getByRole("checkbox")).toHaveCount(2);
|
||||
await expect(dialog.getByRole("checkbox", { name: "recipient-3@example.test" })).toHaveCount(0);
|
||||
await dialog.getByRole("textbox").fill("Both recipients have already received these documents.");
|
||||
await dialog.getByRole("button", { name: "Accept 2 selected messages" }).click();
|
||||
await expect(dialog).not.toBeVisible();
|
||||
expect(fixture.writes).toHaveLength(1);
|
||||
expect(fixture.writes[0]).toMatchObject({ merge_progress: true, build_token: "opaque-build-reference", base_revision: 1,
|
||||
inspection_complete: false, decision_category_key: "attachment-group", reviewed_message_keys: ["entry-1", "entry-2"] });
|
||||
expect(fixture.writes[0].issue_decisions).toEqual([
|
||||
{ job_id: "job-1", decision: "accept", reason: "Both recipients have already received these documents." },
|
||||
{ job_id: "job-2", decision: "accept", reason: "Both recipients have already received these documents." }
|
||||
]);
|
||||
await page.reload();
|
||||
await expect(page.getByRole("button", { name: "Accept similar review conditions", exact: true })).toBeDisabled();
|
||||
await openMessage(page, 2);
|
||||
await expect(page.getByRole("dialog").getByText("Review decision saved. You can leave and continue later.")).toBeVisible();
|
||||
expect(fixture.decisions.has("job-3")).toBe(false);
|
||||
await page.getByRole("dialog").getByRole("button", { name: /^(Close|Schließen)$/ }).last().click();
|
||||
await page.getByRole("button", { name: /^(Complete review|Prüfung abschließen)$/ }).click();
|
||||
await expect.poll(() => fixture.writes.length).toBe(2);
|
||||
expect(fixture.writes[1].inspection_complete).toBe(true);
|
||||
const attachmentsPreflight = page.locator(".deliverability-preflight-item").filter({ has: page.getByText(/^(Attachments|Anhänge)$/) });
|
||||
await expect(attachmentsPreflight).toHaveAttribute("data-state", "ready");
|
||||
await expect(page.getByText(/14 validation warning.*need review/)).toHaveCount(0);
|
||||
await page.reload();
|
||||
await expect(attachmentsPreflight).toHaveAttribute("data-state", "ready");
|
||||
await expect(page.getByText(/14 validation warning.*need review/)).toHaveCount(0);
|
||||
expect(fixture.errors).toEqual([]);
|
||||
});
|
||||
|
||||
test("read-only campaign access permits inspection but no review decisions", async ({ page }) => {
|
||||
const fixture = await install(page, { readOnly: true });
|
||||
await expect(page.getByRole("button", { name: "Accept similar review conditions", exact: true })).toBeDisabled();
|
||||
await expect(page.getByRole("button", { name: /^(Complete review|Prüfung abschließen)$/ })).toBeDisabled();
|
||||
const dialog = await openMessage(page);
|
||||
const accept = dialog.getByRole("button", { name: "Accept and continue", exact: true });
|
||||
if (await accept.count()) await expect(accept).toBeDisabled();
|
||||
expect(fixture.writes).toHaveLength(0);
|
||||
expect(fixture.errors).toEqual([]);
|
||||
});
|
||||
|
||||
test("concurrent reviewer conflict refreshes evidence without replaying or overwriting their decision", async ({ page }) => {
|
||||
const fixture = await install(page, { conflictFirst: true });
|
||||
const dialog = await openMessage(page);
|
||||
await dialog.getByRole("textbox", { name: /^Why is this acceptable\?/ }).fill("Keep my independently inspected exception.");
|
||||
await dialog.getByRole("button", { name: "Accept and continue", exact: true }).click();
|
||||
await expect(dialog.getByText(/Another reviewer saved a decision/)).toBeVisible();
|
||||
await expect(dialog.getByRole("textbox", { name: /^Why is this acceptable\?/ })).toHaveValue("Keep my independently inspected exception.");
|
||||
await expect(dialog.getByText("recipient-1@example.test", { exact: true }).first()).toBeVisible();
|
||||
expect(fixture.writes).toHaveLength(1);
|
||||
expect(fixture.decisions.has("job-1")).toBe(false);
|
||||
expect(fixture.decisions.get("job-2")?.reason).toBe("Another reviewer checked this document.");
|
||||
expect(fixture.reads.filter(path => path.endsWith("/versions/review-version"))).toHaveLength(1);
|
||||
await dialog.getByRole("button", { name: "Accept and continue", exact: true }).click();
|
||||
await expect(dialog).not.toBeVisible();
|
||||
expect(fixture.writes).toHaveLength(2);
|
||||
expect(fixture.writes[1].base_revision).toBe(2);
|
||||
expect(fixture.writes[1].issue_decisions).toEqual([{ job_id: "job-1", decision: "accept", reason: "Keep my independently inspected exception." }]);
|
||||
expect(fixture.decisions.get("job-2")?.actor_user_id).toBe("other-reviewer");
|
||||
expect(fixture.errors).toEqual([]);
|
||||
});
|
||||
|
||||
test("one four-state filter distinguishes automatic, accepted, warning, blocked and excluded messages", async ({ page }) => {
|
||||
const fixture = await install(page, { stateMatrix: true, language: "de" });
|
||||
const section = page.locator("#campaign-built-message-details");
|
||||
const table = section.getByRole("table");
|
||||
const stateCells = table.locator('.data-grid-body-cell[data-column-id="messageState"]');
|
||||
await expect(table.locator('[data-column-id="validation"]')).toHaveCount(0);
|
||||
await expect(table.locator('[data-column-id="reviewed"]')).toHaveCount(0);
|
||||
await expect(stateCells).toHaveText(["Prüfung erforderlich", "Bereit", "Ausgeschlossen", "Prüfung erforderlich", "Blockiert", "Bereit"]);
|
||||
await expect(table.locator('.data-grid-body-cell[data-column-id="stateExplanation"]').nth(1)).toContainText("keine manuelle Entscheidung erforderlich");
|
||||
await expect(table.locator('.data-grid-body-cell[data-column-id="stateExplanation"]').nth(5)).toContainText("gespeicherter Prüfentscheidung");
|
||||
await table.locator('.data-grid-header-cell[data-column-id="messageState"] .data-grid-filter-trigger').click();
|
||||
const filter = page.locator(".data-grid-filter-popover");
|
||||
await expect(filter.getByRole("checkbox")).toHaveCount(4);
|
||||
for (const label of ["Bereit", "Prüfung erforderlich", "Blockiert", "Ausgeschlossen"]) await expect(filter.getByRole("checkbox", { name: label, exact: true })).toBeVisible();
|
||||
await filter.getByRole("button", { name: "Alle abwählen", exact: true }).click();
|
||||
await filter.getByRole("checkbox", { name: "Prüfung erforderlich", exact: true }).check();
|
||||
await expect(stateCells).toHaveText(["Prüfung erforderlich", "Prüfung erforderlich"]);
|
||||
await filter.getByRole("button", { name: /^(Close filter|Filter schließen)$/ }).click();
|
||||
// The convenience action uses the very same derived state, adding blockers
|
||||
// but never reintroducing accepted rows just because their build said ask.
|
||||
await section.getByRole("button", { name: /^(Show review candidates only|Nur Prüfkandidaten anzeigen)$/ }).click();
|
||||
await expect(stateCells).toHaveText(["Prüfung erforderlich", "Prüfung erforderlich", "Blockiert"]);
|
||||
await section.getByRole("button", { name: /^(Show all messages|Alle Nachrichten anzeigen)$/ }).click();
|
||||
await expect(stateCells).toHaveCount(6);
|
||||
expect(fixture.writes).toHaveLength(0);
|
||||
expect(fixture.errors).toEqual([]);
|
||||
});
|
||||
|
||||
test("rebuilding a version loads its new review rows after the build busy state clears", async ({ page }) => {
|
||||
const fixture = await install(page);
|
||||
expect(fixture.jobRequests).toBe(1);
|
||||
await page.getByRole("button", { name: /^(Build again|Erneut erstellen|Erneut bauen)$/ }).click();
|
||||
const table = page.getByRole("table", { name: "campaign-review-campaign-workflow-built-messages", exact: true });
|
||||
await expect(table.getByText("Rebuilt Message 1", { exact: true })).toBeVisible();
|
||||
await expect(table.getByText("Message 1", { exact: true })).toHaveCount(0);
|
||||
expect(fixture.builds).toHaveLength(1);
|
||||
expect(fixture.jobRequests).toBe(2);
|
||||
await table.getByRole("button", { name: /^(Review|Prüfung)$/ }).first().click();
|
||||
const dialog = page.getByRole("dialog");
|
||||
await dialog.getByRole("textbox", { name: /^Why is this acceptable\?/ }).fill("Reviewed the rebuilt evidence.");
|
||||
await dialog.getByRole("button", { name: "Accept and continue", exact: true }).click();
|
||||
await expect.poll(() => fixture.writes.length).toBe(1);
|
||||
expect(fixture.writes[0]).toMatchObject({ build_token: "rebuilt-reference-2", base_revision: 2 });
|
||||
expect(fixture.errors).toEqual([]);
|
||||
});
|
||||
|
||||
test("a delayed old-version job response cannot replace the newly selected build", async ({ page }) => {
|
||||
let releaseOld!: () => void;
|
||||
const fixture = await install(page, { holdFirstJobs: new Promise<void>((resolve) => { releaseOld = resolve; }) });
|
||||
await page.getByRole("button", { name: "Switch fixture version", exact: true }).click();
|
||||
await expect(page).toHaveURL(/version=replacement-version/);
|
||||
await expect.poll(() => fixture.reads.filter(path => path.endsWith("/workspace/delta")).length).toBeGreaterThanOrEqual(2);
|
||||
releaseOld();
|
||||
const table = page.getByRole("table", { name: "campaign-review-campaign-workflow-built-messages", exact: true });
|
||||
await expect(table.getByText("Replacement Message 1", { exact: true })).toBeVisible();
|
||||
await expect(table.getByText("Message 1", { exact: true })).toHaveCount(0);
|
||||
expect(fixture.jobRequests).toBe(2);
|
||||
expect(fixture.writes).toHaveLength(0);
|
||||
expect(fixture.errors).toEqual([]);
|
||||
});
|
||||
|
||||
test("an initial jobs failure stops automatic retries but a manual retry can recover", async ({ page }) => {
|
||||
const fixture = await install(page, { rejectFirstJobs: true });
|
||||
await expect(page.getByText(/Fixture initial review load failed/)).toBeVisible();
|
||||
const retry = page.getByRole("button", { name: /^(Load review|Prüfung laden)$/ });
|
||||
await expect(retry).toBeEnabled();
|
||||
await page.waitForLoadState("networkidle");
|
||||
expect(fixture.jobRequests).toBe(1);
|
||||
await retry.click();
|
||||
await expect(page.getByText("Message 1", { exact: true }).first()).toBeVisible();
|
||||
expect(fixture.jobRequests).toBe(2);
|
||||
expect(fixture.errors).toEqual([]);
|
||||
});
|
||||
@@ -0,0 +1,145 @@
|
||||
import { expect, test, type Page } from "@playwright/test";
|
||||
|
||||
async function install(page: Page, options: { rejectSave?: boolean; rejectReload?: boolean; conflict?: boolean; hold?: Promise<void>; holdFirstRead?: Promise<void> } = {}) {
|
||||
page.on("pageerror", (error) => { console.error("Campaign saving fixture:", error.message); });
|
||||
let revision = 1;
|
||||
let subject = "Original subject";
|
||||
let rejected = false;
|
||||
const writes: Record<string, unknown>[] = [];
|
||||
let reads = 0;
|
||||
const version = () => ({ id: "version-a", campaign_id: "campaign-a", version_number: 1,
|
||||
edit_revision: revision, strong_etag: `"version-a:${revision}"`, editor_state: {},
|
||||
current_flow: "manual", current_step: "template", workflow_state: "editing", is_complete: false,
|
||||
updated_at: "2026-09-07T10:00:00Z", raw_json: { campaign: { name: "Original" }, template: { subject, text: "" }, server: {} } });
|
||||
await page.route((url) => url.pathname.startsWith("/api/"), async (route) => {
|
||||
if (route.request().method() === "POST" && route.request().url().endsWith("/autosave")) {
|
||||
const body = route.request().postDataJSON();
|
||||
writes.push(body);
|
||||
if (options.hold) await options.hold;
|
||||
if (options.rejectSave && !rejected) {
|
||||
rejected = true;
|
||||
return route.fulfill({ status: 422, json: { detail: "Fixture policy denied this save" } });
|
||||
}
|
||||
if ((options.conflict && !rejected) || body.base_revision !== revision) {
|
||||
rejected = true;
|
||||
if (options.conflict) { revision = 2; subject = "Other editor's subject"; }
|
||||
return route.fulfill({ status: 409, json: { detail: { code: "revision_conflict", resource: { type: "campaign_version", id: "version-a" }, current_revision: revision, submitted_base_revision: body.base_revision, retryable: true } } });
|
||||
}
|
||||
subject = body.campaign_json.template.subject;
|
||||
revision += 1;
|
||||
return route.fulfill({ json: version() });
|
||||
}
|
||||
if (route.request().method() === "GET" && route.request().url().includes("/versions/version-a")) {
|
||||
reads++;
|
||||
const snapshot = version();
|
||||
if (reads === 1 && options.holdFirstRead) await options.holdFirstRead;
|
||||
return options.rejectReload ? route.fulfill({ status: 503, json: { detail: "Fixture refresh unavailable" } }) : route.fulfill({ json: snapshot });
|
||||
}
|
||||
return route.abort();
|
||||
});
|
||||
await page.goto("/?campaign-saving&language=en");
|
||||
await expect(page.getByRole("textbox", { name: "Subject", exact: true })).toHaveValue("Original subject");
|
||||
return { writes, reads: () => reads, subject: () => subject };
|
||||
}
|
||||
|
||||
test("a rejected campaign save retains its draft and retries only on explicit request", async ({ page }) => {
|
||||
const fixture = await install(page, { rejectSave: true });
|
||||
await page.getByRole("textbox", { name: "Subject", exact: true }).fill("Unsaved work");
|
||||
await page.getByRole("button", { name: "Save draft", exact: true }).click();
|
||||
await expect(page.getByTestId("save-error")).toContainText("Fixture policy denied");
|
||||
await expect(page.getByTestId("save-dirty")).toHaveText("true");
|
||||
await expect(page.getByRole("textbox", { name: "Subject", exact: true })).toHaveValue("Unsaved work");
|
||||
expect(fixture.writes).toHaveLength(1);
|
||||
await page.getByRole("button", { name: "Save draft", exact: true }).click();
|
||||
await expect(page.getByTestId("save-result")).toHaveText("true");
|
||||
await expect(page.getByTestId("save-dirty")).toHaveText("false");
|
||||
expect(fixture.writes).toHaveLength(2);
|
||||
expect(fixture.subject()).toBe("Unsaved work");
|
||||
});
|
||||
|
||||
test("duplicate save requests coalesce and newer typing survives the older acknowledgement", async ({ page }) => {
|
||||
let release!: () => void;
|
||||
const fixture = await install(page, { hold: new Promise<void>((resolve) => { release = resolve; }) });
|
||||
await page.getByRole("textbox", { name: "Subject", exact: true }).fill("Submitted work");
|
||||
await page.getByRole("button", { name: "Request save twice", exact: true }).click();
|
||||
await expect.poll(() => fixture.writes.length).toBe(1);
|
||||
await expect(page.getByRole("button", { name: "Save draft", exact: true })).toBeDisabled();
|
||||
await page.getByRole("textbox", { name: "Subject", exact: true }).fill("Newer unsaved work");
|
||||
release();
|
||||
await expect(page.getByTestId("save-busy")).toHaveText("false");
|
||||
await expect(page.getByTestId("save-dirty")).toHaveText("true");
|
||||
await expect(page.getByTestId("save-status")).toContainText("newer changes are still unsaved");
|
||||
await expect(page.getByRole("textbox", { name: "Subject", exact: true })).toHaveValue("Newer unsaved work");
|
||||
expect(fixture.subject()).toBe("Submitted work");
|
||||
expect(fixture.writes).toHaveLength(1);
|
||||
await page.getByRole("button", { name: "Save draft", exact: true }).click();
|
||||
await expect(page.getByRole("alertdialog", { name: "Concurrent changes" })).toBeVisible();
|
||||
await page.getByRole("tab", { name: "Use my change", exact: true }).click();
|
||||
await page.getByRole("button", { name: "Apply resolution", exact: true }).click();
|
||||
await expect(page.getByTestId("save-dirty")).toHaveText("false");
|
||||
expect(fixture.subject()).toBe("Newer unsaved work");
|
||||
});
|
||||
|
||||
test("failed follow-up refresh never turns an acknowledged save into save failed", async ({ page }) => {
|
||||
const fixture = await install(page, { rejectReload: true });
|
||||
await page.getByRole("textbox", { name: "Subject", exact: true }).fill("Committed work");
|
||||
await page.getByRole("button", { name: "Save draft", exact: true }).click();
|
||||
await expect(page.getByTestId("save-result")).toHaveText("true");
|
||||
await expect(page.getByTestId("save-status")).toContainText("Saved");
|
||||
await expect(page.getByTestId("save-error")).toContainText("was saved");
|
||||
await expect(page.getByTestId("save-dirty")).toHaveText("false");
|
||||
expect(fixture.writes).toHaveLength(1);
|
||||
});
|
||||
|
||||
test("cancelling a revision conflict ends saving and keeps the local draft", async ({ page }) => {
|
||||
const fixture = await install(page, { conflict: true });
|
||||
await page.getByRole("textbox", { name: "Subject", exact: true }).fill("My conflicting work");
|
||||
await page.getByRole("button", { name: "Save draft", exact: true }).click();
|
||||
const dialog = page.getByRole("alertdialog", { name: "Concurrent changes" });
|
||||
await expect(dialog).toBeVisible();
|
||||
await dialog.getByRole("button", { name: "Cancel", exact: true }).click();
|
||||
await expect(page.getByTestId("save-busy")).toHaveText("false");
|
||||
await expect(page.getByTestId("save-status")).toContainText("Save cancelled");
|
||||
await expect(page.getByTestId("save-dirty")).toHaveText("true");
|
||||
await expect(page.getByRole("textbox", { name: "Subject", exact: true })).toHaveValue("My conflicting work");
|
||||
expect(fixture.writes).toHaveLength(1);
|
||||
});
|
||||
|
||||
test("background version refresh and failed discard do not silently clear unsaved work", async ({ page }) => {
|
||||
await install(page, { rejectReload: true });
|
||||
await page.getByRole("textbox", { name: "Subject", exact: true }).fill("Keep this draft");
|
||||
await page.getByRole("button", { name: "Background refresh", exact: true }).click();
|
||||
await expect(page.getByRole("textbox", { name: "Subject", exact: true })).toHaveValue("Keep this draft");
|
||||
await page.getByRole("button", { name: "Discard draft", exact: true }).click();
|
||||
await expect(page.getByTestId("save-error")).toContainText("Fixture refresh unavailable");
|
||||
await expect(page.getByTestId("save-dirty")).toHaveText("true");
|
||||
await expect(page.getByRole("textbox", { name: "Subject", exact: true })).toHaveValue("Keep this draft");
|
||||
});
|
||||
|
||||
test("new typing during a discard refresh is retained instead of being replaced by the late response", async ({ page }) => {
|
||||
let release!: () => void;
|
||||
const fixture = await install(page, { holdFirstRead: new Promise<void>(resolve => { release = resolve; }) });
|
||||
await page.getByRole("textbox", { name: "Subject", exact: true }).fill("Earlier draft");
|
||||
await page.getByRole("button", { name: "Discard draft", exact: true }).click();
|
||||
await expect.poll(fixture.reads).toBe(1);
|
||||
await page.getByRole("textbox", { name: "Subject", exact: true }).fill("Newer draft");
|
||||
release();
|
||||
await expect(page.getByTestId("save-error")).toContainText("Discard cancelled");
|
||||
await expect(page.getByTestId("save-dirty")).toHaveText("true");
|
||||
await expect(page.getByRole("textbox", { name: "Subject", exact: true })).toHaveValue("Newer draft");
|
||||
expect(fixture.writes).toHaveLength(0);
|
||||
});
|
||||
|
||||
test("a late discard response cannot overwrite an acknowledged save", async ({ page }) => {
|
||||
let release!: () => void;
|
||||
const fixture = await install(page, { holdFirstRead: new Promise<void>(resolve => { release = resolve; }) });
|
||||
await page.getByRole("textbox", { name: "Subject", exact: true }).fill("Saved work");
|
||||
await page.getByRole("button", { name: "Discard draft", exact: true }).click();
|
||||
await expect.poll(fixture.reads).toBe(1);
|
||||
await page.getByRole("button", { name: "Save draft", exact: true }).click();
|
||||
await expect(page.getByTestId("save-result")).toHaveText("true");
|
||||
release();
|
||||
await expect(page.getByRole("textbox", { name: "Subject", exact: true })).toHaveValue("Saved work");
|
||||
await expect(page.getByTestId("save-dirty")).toHaveText("false");
|
||||
expect(fixture.subject()).toBe("Saved work");
|
||||
});
|
||||
@@ -0,0 +1,127 @@
|
||||
import { expect, test, type Page, type Route } from "@playwright/test";
|
||||
|
||||
function deferred() {
|
||||
let resolve!: () => void;
|
||||
const promise = new Promise<void>((done) => { resolve = done; });
|
||||
return { promise, resolve };
|
||||
}
|
||||
|
||||
function response(campaignId = "campaign-a", revision = 1, versionId = `${campaignId}-version`, hasMore = false) {
|
||||
const version = { id: versionId, campaign_id: campaignId, version_number: 1, edit_revision: revision, raw_json: {} };
|
||||
return {
|
||||
campaign: { id: campaignId, current_version_id: versionId },
|
||||
versions: [version], current_version: version, summary: null,
|
||||
deleted: [], watermark: `watermark-${campaignId}-${revision}`, has_more: hasMore, full: true
|
||||
};
|
||||
}
|
||||
|
||||
async function fixture(page: Page) {
|
||||
const requests: URL[] = [];
|
||||
const state = {
|
||||
handler: async (route: Route) => {
|
||||
const url = new URL(route.request().url());
|
||||
const campaignId = url.pathname.split("/")[4];
|
||||
await route.fulfill({ json: response(campaignId, 1, url.searchParams.get("version_id") ?? undefined) });
|
||||
}
|
||||
};
|
||||
await page.route((url) => url.pathname.startsWith("/api/"), async (route) => {
|
||||
const url = new URL(route.request().url());
|
||||
if (url.pathname.endsWith("/workspace/delta")) {
|
||||
requests.push(url);
|
||||
await state.handler(route);
|
||||
} else await route.fulfill({ json: {} });
|
||||
});
|
||||
await page.goto("/?campaign-workspace");
|
||||
await expect(page.getByTestId("workspace-version")).toHaveText("campaign-a-version");
|
||||
await expect(page.getByTestId("workspace-loading")).toHaveText("false");
|
||||
requests.length = 0;
|
||||
return { state, requests };
|
||||
}
|
||||
|
||||
test("failed refresh retains saved workspace and retries without a stale watermark", async ({ page }) => {
|
||||
const { state, requests } = await fixture(page);
|
||||
state.handler = async (route) => { await route.fulfill({ status: 503, json: { detail: "Refresh unavailable" } }); };
|
||||
await page.getByRole("button", { name: "Force reload workspace", exact: true }).click();
|
||||
await expect(page.getByTestId("workspace-error")).toContainText("Refresh unavailable");
|
||||
await expect(page.getByTestId("workspace-loading")).toHaveText("false");
|
||||
await expect(page.getByTestId("workspace-version")).toHaveText("campaign-a-version");
|
||||
state.handler = async (route) => { await route.fulfill({ json: response("campaign-a", 2) }); };
|
||||
await page.getByRole("button", { name: "Reload workspace", exact: true }).click();
|
||||
await expect(page.getByTestId("workspace-revision")).toHaveText("2");
|
||||
expect(requests[requests.length - 1].searchParams.has("since")).toBe(false);
|
||||
await expect(page.getByTestId("workspace-error")).toBeEmpty();
|
||||
});
|
||||
|
||||
for (const staleFails of [false, true]) {
|
||||
test(`newer reload wins over an older ${staleFails ? "failed" : "successful"} response`, async ({ page }) => {
|
||||
const { state } = await fixture(page);
|
||||
const pending = deferred(); const finished = deferred();
|
||||
let requestCount = 0;
|
||||
state.handler = async (route) => {
|
||||
requestCount += 1;
|
||||
if (requestCount === 1) {
|
||||
await pending.promise;
|
||||
await route.fulfill(staleFails ? { status: 503, json: { detail: "Obsolete error" } } : { json: response("campaign-a", 2) });
|
||||
finished.resolve();
|
||||
} else await route.fulfill({ json: response("campaign-a", 3) });
|
||||
};
|
||||
await page.getByRole("button", { name: "Reload workspace", exact: true }).click();
|
||||
await expect.poll(() => requestCount).toBe(1);
|
||||
await page.getByRole("button", { name: "Reload workspace", exact: true }).click();
|
||||
await expect(page.getByTestId("workspace-revision")).toHaveText("3");
|
||||
pending.resolve(); await finished.promise;
|
||||
await page.evaluate(() => new Promise((done) => requestAnimationFrame(() => requestAnimationFrame(done))));
|
||||
await expect(page.getByTestId("workspace-revision")).toHaveText("3");
|
||||
await expect(page.getByTestId("workspace-loading")).toHaveText("false");
|
||||
await expect(page.getByTestId("workspace-error")).toBeEmpty();
|
||||
});
|
||||
}
|
||||
|
||||
for (const change of ["campaign", "identity", "version"] as const) {
|
||||
test(`changing ${change} discards old responses and cancels obsolete pagination`, async ({ page }) => {
|
||||
const { state, requests } = await fixture(page);
|
||||
const pending = deferred(); const finished = deferred();
|
||||
let requestCount = 0;
|
||||
state.handler = async (route) => {
|
||||
requestCount += 1;
|
||||
if (requestCount === 1) {
|
||||
await pending.promise;
|
||||
await route.fulfill({ json: response("campaign-a", 99, "obsolete-version", true) });
|
||||
finished.resolve();
|
||||
} else await route.fulfill({ json: response(change === "campaign" ? "campaign-b" : "campaign-a", 2, "fresh-version") });
|
||||
};
|
||||
await page.getByRole("button", { name: "Reload workspace", exact: true }).click();
|
||||
await expect.poll(() => requestCount).toBe(1);
|
||||
await page.getByRole("button", { name: { campaign: "Switch campaign", identity: "Switch identity", version: "Select version" }[change] }).click();
|
||||
await expect(page.getByTestId("workspace-version")).toHaveText("fresh-version");
|
||||
pending.resolve(); await finished.promise;
|
||||
await page.evaluate(() => new Promise((done) => requestAnimationFrame(() => requestAnimationFrame(done))));
|
||||
await expect(page.getByTestId("workspace-version")).toHaveText("fresh-version");
|
||||
expect(requests).toHaveLength(2);
|
||||
expect(requests[1].searchParams.has("since")).toBe(false);
|
||||
});
|
||||
}
|
||||
|
||||
test("failed initial load for a different campaign never reveals the old campaign", async ({ page }) => {
|
||||
const { state } = await fixture(page);
|
||||
state.handler = async (route) => { await route.fulfill({ status: 403, json: { detail: "Campaign unavailable" } }); };
|
||||
await page.getByRole("button", { name: "Switch campaign" }).click();
|
||||
await expect(page.getByTestId("workspace-error")).toContainText("Campaign unavailable");
|
||||
await expect(page.getByTestId("workspace-campaign")).toHaveText("none");
|
||||
await expect(page.getByTestId("workspace-version")).toHaveText("none");
|
||||
});
|
||||
|
||||
test("a failed later page never publishes a partially refreshed workspace", async ({ page }) => {
|
||||
const { state } = await fixture(page);
|
||||
let requestCount = 0;
|
||||
state.handler = async (route) => {
|
||||
requestCount += 1;
|
||||
await route.fulfill(requestCount === 1
|
||||
? { json: response("campaign-a", 4, "partial-version", true) }
|
||||
: { status: 503, json: { detail: "Second page unavailable" } });
|
||||
};
|
||||
await page.getByRole("button", { name: "Reload workspace", exact: true }).click();
|
||||
await expect(page.getByTestId("workspace-error")).toContainText("Second page unavailable");
|
||||
await expect(page.getByTestId("workspace-version")).toHaveText("campaign-a-version");
|
||||
await expect(page.getByTestId("workspace-revision")).toHaveText("1");
|
||||
});
|
||||
@@ -0,0 +1,177 @@
|
||||
import { expect, test, type Page } from "@playwright/test";
|
||||
|
||||
const credential = {
|
||||
id: "fixture-credential", name: "Mailbox login", description: "Fixture metadata only",
|
||||
credential_kind: "username_password", scope_type: "tenant", scope_id: null,
|
||||
public_data: { username: "fixture@example.test" }, secret_configured: true,
|
||||
allowed_modules: ["mail"], allowed_server_refs: ["mail:smtp-server", "mail:imap-server", "mail:deleted-server"],
|
||||
inherit_to_lower_scopes: false, is_active: true,
|
||||
created_at: "2026-01-01T12:00:00Z", updated_at: "2026-01-01T12:00:00Z"
|
||||
};
|
||||
|
||||
async function installFixtures(page: Page, release: Promise<void>, options: { failFirst?: boolean; paginated?: boolean; denied?: boolean; retrySave?: Promise<void> } = {}) {
|
||||
let loads = 0;
|
||||
let savedCredential = credential;
|
||||
const saves: Record<string, unknown>[] = [];
|
||||
const forbiddenRequests: string[] = [];
|
||||
await page.route((url) => url.pathname.startsWith("/api/"), async (route) => {
|
||||
const request = route.request();
|
||||
const url = new URL(request.url());
|
||||
if (request.method() === "GET" && url.pathname === "/api/v1/credentials") {
|
||||
await route.fulfill({ json: { credentials: [savedCredential] } });
|
||||
return;
|
||||
}
|
||||
if (request.method() === "PATCH" && url.pathname === `/api/v1/credentials/${credential.id}` && options.retrySave) {
|
||||
const body = request.postDataJSON() as Record<string, unknown>;
|
||||
saves.push(body);
|
||||
await options.retrySave;
|
||||
if (saves.length === 1) {
|
||||
await route.fulfill({ status: 503, json: { detail: "Fixture save failed; keep your draft and retry." } });
|
||||
} else {
|
||||
savedCredential = { ...credential, name: String(body.name) };
|
||||
await route.fulfill({ json: savedCredential });
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (request.method() === "GET" && url.pathname === "/api/v1/platform/modules") {
|
||||
await route.fulfill({ json: { modules: [{ id: "mail", label: "Mail", version: "1" }] } });
|
||||
return;
|
||||
}
|
||||
if (request.method() === "GET" && url.pathname === "/api/v1/mail/settings/delta") {
|
||||
loads += 1;
|
||||
const number = loads;
|
||||
await release;
|
||||
if (options.denied || (options.failFirst && number === 1)) {
|
||||
await route.fulfill({ status: options.denied ? 403 : 503, json: { detail: "Fixture catalogue unavailable" } });
|
||||
return;
|
||||
}
|
||||
expect(url.searchParams.get("scope_type")).toBe("tenant");
|
||||
expect(url.searchParams.get("include_inactive")).toBe("true");
|
||||
if (options.paginated && url.searchParams.get("since")) {
|
||||
await route.fulfill({ json: { full: false, has_more: false, watermark: "fixture-watermark-2", profiles: [],
|
||||
deleted: [{ id: "deleted-profile", resource_type: "mail_profile" }] } });
|
||||
return;
|
||||
}
|
||||
await route.fulfill({ json: {
|
||||
full: true, has_more: Boolean(options.paginated), watermark: "fixture-watermark", deleted: [],
|
||||
profiles: [{ id: "fixture-profile", name: "Town hall", slug: "town-hall", servers: [
|
||||
{ id: "smtp-server", name: "Outgoing town hall", protocol: "smtp", is_active: true, scope_type: "tenant" },
|
||||
{ id: "imap-server", name: "Incoming town hall", protocol: "imap", is_active: false, scope_type: "tenant" }
|
||||
] }, ...(options.paginated ? [{ id: "deleted-profile", name: "Removed profile", slug: "removed", servers: [
|
||||
{ id: "deleted-server", name: "Removed server", protocol: "smtp", is_active: true, scope_type: "tenant" }
|
||||
] }] : [])]
|
||||
} });
|
||||
return;
|
||||
}
|
||||
forbiddenRequests.push(`${request.method()} ${url.pathname}`);
|
||||
await route.fulfill({ status: 403, json: { detail: "Unexpected fixture request" } });
|
||||
});
|
||||
return { get loads() { return loads; }, forbiddenRequests, saves };
|
||||
}
|
||||
|
||||
test("credential server labels resolve on first opening despite StrictMode cleanup", async ({ page }) => {
|
||||
page.on("pageerror", (error) => { throw error; });
|
||||
let release!: () => void;
|
||||
const fixture = await installFixtures(page, new Promise<void>((resolve) => { release = resolve; }));
|
||||
await page.goto("/?credential-references&language=en");
|
||||
await page.getByRole("button", { name: "Edit Mailbox login", exact: true }).click();
|
||||
const dialog = page.getByRole("dialog", { name: "Edit reusable credential", exact: true });
|
||||
await expect(dialog).toBeVisible();
|
||||
await expect.poll(() => fixture.loads).toBeGreaterThan(0);
|
||||
release();
|
||||
const servers = dialog.getByRole("list", { name: "Credential servers selected" });
|
||||
await expect(servers).toContainText("Outgoing town hall");
|
||||
await expect(servers).toContainText("Incoming town hall");
|
||||
await expect(servers.locator(".is-inactive")).toContainText("Incoming town hall");
|
||||
await expect(servers.locator(".is-unavailable")).toContainText("mail:deleted-server");
|
||||
await expect(servers.locator(".is-unavailable")).toHaveCount(1);
|
||||
await dialog.locator('input[data-help-context-id="access.credentials.field.name"]').fill("Renamed metadata only");
|
||||
await expect(servers).toContainText("Outgoing town hall");
|
||||
expect(fixture.loads).toBe(1);
|
||||
expect(fixture.forbiddenRequests).toEqual([]);
|
||||
});
|
||||
|
||||
test("paginated server catalogues remove deleted profiles while retaining their selected references", async ({ page }) => {
|
||||
const fixture = await installFixtures(page, Promise.resolve(), { paginated: true });
|
||||
await page.goto("/?credential-references&language=en");
|
||||
await page.getByRole("button", { name: "Edit Mailbox login", exact: true }).click();
|
||||
const servers = page.getByRole("list", { name: "Credential servers selected" });
|
||||
await expect(servers).toContainText("Outgoing town hall");
|
||||
await expect(servers.locator(".is-unavailable")).toHaveCount(1);
|
||||
await expect(servers.locator(".is-unavailable")).toContainText("mail:deleted-server");
|
||||
await expect(servers).not.toContainText("Removed server");
|
||||
expect(fixture.loads).toBe(2);
|
||||
expect(fixture.forbiddenRequests).toEqual([]);
|
||||
});
|
||||
|
||||
test("denied server metadata remains unavailable without dropping stored references", async ({ page }) => {
|
||||
const fixture = await installFixtures(page, Promise.resolve(), { denied: true });
|
||||
await page.goto("/?credential-references&language=en");
|
||||
await page.getByRole("button", { name: "Edit Mailbox login", exact: true }).click();
|
||||
const servers = page.getByRole("list", { name: "Credential servers selected" });
|
||||
await expect(servers.locator(".is-unavailable")).toHaveCount(3);
|
||||
for (const reference of credential.allowed_server_refs) await expect(servers).toContainText(reference);
|
||||
await expect(servers).not.toContainText("Outgoing town hall");
|
||||
expect(fixture.forbiddenRequests).toEqual([]);
|
||||
});
|
||||
|
||||
test("temporary server-catalogue failures can recover by reopening the editor", async ({ page }) => {
|
||||
const fixture = await installFixtures(page, Promise.resolve(), { failFirst: true });
|
||||
await page.goto("/?credential-references&language=en");
|
||||
await page.getByRole("button", { name: "Edit Mailbox login", exact: true }).click();
|
||||
await expect(page.getByRole("list", { name: "Credential servers selected" }).locator(".is-unavailable")).toHaveCount(3);
|
||||
await page.getByRole("dialog").getByRole("button", { name: "Cancel", exact: true }).click();
|
||||
await page.getByRole("button", { name: "Edit Mailbox login", exact: true }).click();
|
||||
await expect(page.getByRole("list", { name: "Credential servers selected" })).toContainText("Outgoing town hall");
|
||||
expect(fixture.loads).toBe(2);
|
||||
expect(fixture.forbiddenRequests).toEqual([]);
|
||||
});
|
||||
|
||||
test("closing while servers load does not poison the next credential editor", async ({ page }) => {
|
||||
let release!: () => void;
|
||||
const fixture = await installFixtures(page, new Promise<void>((resolve) => { release = resolve; }));
|
||||
await page.goto("/?credential-references&language=en");
|
||||
await page.getByRole("button", { name: "Edit Mailbox login", exact: true }).click();
|
||||
await expect.poll(() => fixture.loads).toBeGreaterThan(0);
|
||||
await page.getByRole("dialog").getByRole("button", { name: "Cancel", exact: true }).click();
|
||||
await page.getByRole("button", { name: "Edit Mailbox login", exact: true }).click();
|
||||
release();
|
||||
const servers = page.getByRole("list", { name: "Credential servers selected" });
|
||||
await expect(servers).toContainText("Outgoing town hall");
|
||||
await expect(servers).toContainText("Incoming town hall");
|
||||
expect(fixture.forbiddenRequests).toEqual([]);
|
||||
});
|
||||
|
||||
test("failed credential saves show their error inside the editor and retain the draft for explicit retry", async ({ page }) => {
|
||||
let finishSave!: () => void;
|
||||
const retrySave = new Promise<void>((resolve) => { finishSave = resolve; });
|
||||
const fixture = await installFixtures(page, Promise.resolve(), { retrySave });
|
||||
await page.goto("/?credential-references&language=en");
|
||||
await page.getByRole("button", { name: "Edit Mailbox login", exact: true }).click();
|
||||
const dialog = page.getByRole("dialog", { name: "Edit reusable credential", exact: true });
|
||||
const name = dialog.locator('input[data-help-context-id="access.credentials.field.name"]');
|
||||
const secret = dialog.locator('input[type="password"]');
|
||||
await name.fill("Renamed mailbox credential");
|
||||
await secret.fill("synthetic-fixture-replacement");
|
||||
await dialog.getByRole("button", { name: "Save credential", exact: true }).click();
|
||||
await expect.poll(() => fixture.saves.length).toBe(1);
|
||||
await expect(name).toBeDisabled();
|
||||
await expect(secret).toBeDisabled();
|
||||
await expect(dialog.getByRole("button", { name: "Cancel", exact: true })).toBeDisabled();
|
||||
await expect(dialog.locator(".dialog-close")).toBeDisabled();
|
||||
await page.keyboard.press("Escape");
|
||||
await expect(dialog).toBeVisible();
|
||||
finishSave();
|
||||
await expect(dialog.getByRole("alert")).toContainText("Fixture save failed; keep your draft and retry.");
|
||||
await expect(page.getByRole("alert")).toHaveCount(1);
|
||||
await expect(name).toHaveValue("Renamed mailbox credential");
|
||||
await expect(secret).toHaveValue("synthetic-fixture-replacement");
|
||||
expect(fixture.saves).toHaveLength(1);
|
||||
await dialog.getByRole("button", { name: "Save credential", exact: true }).click();
|
||||
await expect(dialog).toHaveCount(0);
|
||||
await expect(page.getByRole("button", { name: "Edit Renamed mailbox credential", exact: true })).toBeVisible();
|
||||
expect(fixture.saves).toHaveLength(2);
|
||||
expect(fixture.saves[1]).toEqual(fixture.saves[0]);
|
||||
expect(fixture.saves[1].allowed_server_refs).toEqual(credential.allowed_server_refs);
|
||||
expect(fixture.forbiddenRequests).toEqual([]);
|
||||
});
|
||||
@@ -0,0 +1,141 @@
|
||||
import { expect, test, type Page } from "@playwright/test";
|
||||
|
||||
const actionCell = (page: Page) => page.locator('.data-grid-body-cell[data-column-id="actions"]');
|
||||
const header = (page: Page, id: string) => page.locator(`.data-grid-header-cell[data-column-id="${id}"]`);
|
||||
const width = (page: Page, id: string) => header(page, id).evaluate((element) => element.getBoundingClientRect().width);
|
||||
|
||||
async function expectActionsUnclipped(page: Page) {
|
||||
await expect.poll(async () => actionCell(page).evaluate((cell) => {
|
||||
const bounds = cell.getBoundingClientRect();
|
||||
const scroller = cell.closest(".data-grid-scroll-region")!;
|
||||
const viewport = scroller.getBoundingClientRect();
|
||||
return Array.from(cell.querySelectorAll("button")).every((button) => {
|
||||
const rect = button.getBoundingClientRect();
|
||||
return rect.width >= 35 && rect.left >= bounds.left && rect.right <= bounds.right
|
||||
&& rect.top >= bounds.top && rect.bottom <= bounds.bottom
|
||||
&& rect.left >= viewport.left && rect.right <= viewport.right;
|
||||
});
|
||||
})).toBe(true);
|
||||
}
|
||||
|
||||
test("undersized action tracks fit real controls and stay visible through narrow horizontal scrolling", async ({ page }) => {
|
||||
const errors: string[] = [];
|
||||
page.on("pageerror", (error) => errors.push(error.message));
|
||||
await page.goto("/?data-grid-layout");
|
||||
await expect.poll(() => width(page, "actions")).toBeGreaterThanOrEqual(181);
|
||||
await expectActionsUnclipped(page);
|
||||
await page.getByRole("button", { name: "Narrow grid", exact: true }).click();
|
||||
await expect.poll(() => width(page, "actions")).toBeLessThanOrEqual(160);
|
||||
const scroller = page.locator(".data-grid-scroll-region");
|
||||
await expectActionsUnclipped(page);
|
||||
await scroller.evaluate((element) => { element.scrollLeft = element.scrollWidth / 2; });
|
||||
await expectActionsUnclipped(page);
|
||||
await scroller.evaluate((element) => { element.scrollLeft = element.scrollWidth; });
|
||||
await expectActionsUnclipped(page);
|
||||
await page.getByRole("button", { name: "Inspect Alpha", exact: true }).click();
|
||||
await expect(page.getByTestId("clicked-action")).toHaveText("Inspect Alpha");
|
||||
await scroller.focus();
|
||||
await expect(scroller).toBeFocused();
|
||||
// The application shell's supported viewport floor is 320px; its padded
|
||||
// content area is narrower and must still contain the complete action set.
|
||||
await page.setViewportSize({ width: 320, height: 720 });
|
||||
await expectActionsUnclipped(page);
|
||||
expect(await page.evaluate(() => document.documentElement.scrollWidth <= window.innerWidth)).toBe(true);
|
||||
expect(errors).toEqual([]);
|
||||
});
|
||||
|
||||
test("pointer and keyboard resizing persists, cancels safely, and adapts to container resize", async ({ page }) => {
|
||||
await page.goto("/?data-grid-layout");
|
||||
await expectActionsUnclipped(page);
|
||||
const handle = header(page, "name").getByRole("separator");
|
||||
await expect(handle).toHaveAttribute("aria-orientation", "vertical");
|
||||
const initial = await width(page, "name");
|
||||
await handle.focus();
|
||||
await handle.press("Shift+ArrowRight");
|
||||
await expect.poll(() => width(page, "name")).toBeCloseTo(initial + 40, 0);
|
||||
const box = (await handle.boundingBox())!;
|
||||
await page.mouse.move(box.x + box.width / 2, box.y + box.height / 2);
|
||||
await page.mouse.down();
|
||||
await page.mouse.move(box.x + box.width / 2 + 70, box.y + box.height / 2, { steps: 5 });
|
||||
await expect.poll(() => width(page, "name")).toBeCloseTo(initial + 110, 0);
|
||||
await page.keyboard.press("Escape");
|
||||
await page.mouse.up();
|
||||
await expect.poll(() => width(page, "name")).toBeCloseTo(initial + 40, 0);
|
||||
await page.getByRole("button", { name: "Toggle grid mount", exact: true }).click();
|
||||
await page.getByRole("button", { name: "Toggle grid mount", exact: true }).click();
|
||||
await expect.poll(() => width(page, "name")).toBeCloseTo(initial + 40, 0);
|
||||
await page.getByRole("button", { name: "Narrow grid", exact: true }).click();
|
||||
await expect.poll(() => width(page, "name")).toBeLessThan(initial + 40);
|
||||
await expectActionsUnclipped(page);
|
||||
await page.getByRole("button", { name: "Wide grid", exact: true }).click();
|
||||
await expect.poll(() => width(page, "name")).toBeCloseTo(initial + 40, 0);
|
||||
await handle.press("Enter");
|
||||
await expect.poll(() => width(page, "name")).toBeCloseTo(initial, 0);
|
||||
const resetBox = (await handle.boundingBox())!;
|
||||
await page.mouse.move(resetBox.x + resetBox.width / 2, resetBox.y + resetBox.height / 2);
|
||||
await page.mouse.down();
|
||||
await page.mouse.move(resetBox.x + resetBox.width / 2 + 50, resetBox.y + resetBox.height / 2, { steps: 5 });
|
||||
await page.mouse.up();
|
||||
await expect.poll(() => width(page, "name")).toBeCloseTo(initial + 50, 0);
|
||||
await page.getByRole("button", { name: "Toggle grid mount", exact: true }).click();
|
||||
await page.getByRole("button", { name: "Toggle grid mount", exact: true }).click();
|
||||
await expect.poll(() => width(page, "name")).toBeCloseTo(initial + 50, 0);
|
||||
});
|
||||
|
||||
test("free content tracks retain explicit user resizing on remount", async ({ page }) => {
|
||||
await page.goto("/?data-grid-layout&mode=free");
|
||||
const handle = header(page, "name").getByRole("separator");
|
||||
await expectActionsUnclipped(page);
|
||||
const initial = await width(page, "name");
|
||||
await handle.press("ArrowRight");
|
||||
await expect.poll(() => width(page, "name")).toBeCloseTo(initial + 10, 0);
|
||||
await page.getByRole("button", { name: "Toggle grid mount", exact: true }).click();
|
||||
await page.getByRole("button", { name: "Toggle grid mount", exact: true }).click();
|
||||
await expect.poll(() => width(page, "name")).toBeCloseTo(initial + 10, 0);
|
||||
});
|
||||
|
||||
test("empty and changing action sets reserve the same complete slots", async ({ page }) => {
|
||||
await page.goto("/?data-grid-layout");
|
||||
await expectActionsUnclipped(page);
|
||||
const initial = await width(page, "actions");
|
||||
await page.getByRole("button", { name: "Toggle empty rows", exact: true }).click();
|
||||
await expectActionsUnclipped(page);
|
||||
await expect.poll(() => width(page, "actions")).toBeCloseTo(initial, 0);
|
||||
await page.getByRole("button", { name: "Toggle empty rows", exact: true }).click();
|
||||
await page.getByRole("button", { name: "Toggle extra action", exact: true }).click();
|
||||
await expect.poll(() => width(page, "actions")).toBeGreaterThan(initial + 35);
|
||||
await expectActionsUnclipped(page);
|
||||
});
|
||||
|
||||
test("constrained resizing redistributes tracks without introducing overflow", async ({ page }) => {
|
||||
await page.goto("/?data-grid-layout&mode=constrained");
|
||||
await expectActionsUnclipped(page);
|
||||
const initial = await width(page, "name");
|
||||
await header(page, "name").getByRole("separator").press("Shift+ArrowRight");
|
||||
await expect.poll(() => width(page, "name")).toBeCloseTo(initial + 40, 0);
|
||||
expect(await page.locator(".data-grid-scroll-region").evaluate((element) => element.scrollWidth - element.clientWidth)).toBeLessThanOrEqual(1);
|
||||
await expectActionsUnclipped(page);
|
||||
});
|
||||
|
||||
test("explicit composite action groups include outer controls in their measured minimum", async ({ page }) => {
|
||||
await page.goto("/?data-grid-layout&mode=composite");
|
||||
await expect.poll(() => width(page, "actions")).toBeGreaterThanOrEqual(221);
|
||||
await expectActionsUnclipped(page);
|
||||
await page.getByRole("button", { name: "Narrow grid", exact: true }).click();
|
||||
await expectActionsUnclipped(page);
|
||||
await page.getByRole("button", { name: "Extra control", exact: true }).click();
|
||||
await expect(page.getByTestId("clicked-action")).toHaveText("Extra control");
|
||||
});
|
||||
|
||||
test("oversized explicit sticky minima release stickiness instead of obscuring every data column", async ({ page }) => {
|
||||
await page.goto("/?data-grid-layout&mode=oversized");
|
||||
await expectActionsUnclipped(page);
|
||||
await page.getByRole("button", { name: "Narrow grid", exact: true }).click();
|
||||
await expect(page.locator(".data-grid-shell")).toHaveClass(/data-grid-release-sticky/);
|
||||
const name = page.locator('.data-grid-body-cell[data-column-id="name"]');
|
||||
await expect(name).toBeInViewport();
|
||||
await page.locator(".data-grid-scroll-region").evaluate((element) => { element.scrollLeft = element.scrollWidth; });
|
||||
await expectActionsUnclipped(page);
|
||||
await page.getByRole("button", { name: "Wide grid", exact: true }).click();
|
||||
await expect(page.locator(".data-grid-shell")).not.toHaveClass(/data-grid-release-sticky/);
|
||||
});
|
||||
@@ -0,0 +1,52 @@
|
||||
import { expect, test, type Locator } from "@playwright/test";
|
||||
|
||||
async function expectDialogFits(dialog: Locator) {
|
||||
await expect(dialog).toBeVisible();
|
||||
const sizes = await dialog.evaluate((panel) => {
|
||||
const body = panel.querySelector<HTMLElement>(".dialog-body")!;
|
||||
const rect = panel.getBoundingClientRect();
|
||||
const overflowControls = Array.from(panel.querySelectorAll("input,select,textarea,.dialog-close,.dialog-footer button")).filter((control) => {
|
||||
const bounds = control.getBoundingClientRect();
|
||||
return bounds.left < rect.left - 1 || bounds.right > rect.right + 1;
|
||||
}).map((control) => control.tagName);
|
||||
return { horizontalOverflow: body.scrollWidth - body.clientWidth, left: rect.left, right: rect.right, viewport: window.innerWidth, overflowControls };
|
||||
});
|
||||
expect(sizes.horizontalOverflow).toBeLessThanOrEqual(1);
|
||||
expect(sizes.left).toBeGreaterThanOrEqual(0);
|
||||
expect(sizes.right).toBeLessThanOrEqual(sizes.viewport);
|
||||
expect(sizes.overflowControls).toEqual([]);
|
||||
}
|
||||
|
||||
for (const width of [320, 390, 768, 1280]) {
|
||||
test(`shared dialog keeps narrow form content and local table scrolling inside ${width}px`, async ({ page }) => {
|
||||
await page.setViewportSize({ width, height: 900 });
|
||||
await page.goto("/?dialog-layout");
|
||||
const dialog = page.getByRole("dialog");
|
||||
await expectDialogFits(dialog);
|
||||
const tableScroll = page.getByTestId("dialog-local-scroll");
|
||||
expect(await tableScroll.evaluate((element) => element.scrollWidth > element.clientWidth)).toBe(true);
|
||||
await tableScroll.evaluate((element) => { element.scrollLeft = element.scrollWidth; });
|
||||
expect(await tableScroll.evaluate((element) => element.scrollLeft)).toBeGreaterThan(0);
|
||||
await expectDialogFits(dialog);
|
||||
});
|
||||
|
||||
for (const language of ["en", "de"]) {
|
||||
test(`actual Templates Add dialog fits ${width}px in ${language}`, async ({ page }) => {
|
||||
const unexpectedWrites: string[] = [];
|
||||
await page.route((url) => url.pathname.startsWith("/api/"), async (route) => {
|
||||
const request = route.request();
|
||||
if (request.method() !== "GET") unexpectedWrites.push(`${request.method()} ${request.url()}`);
|
||||
await route.fulfill({ json: { items: [] } });
|
||||
});
|
||||
await page.setViewportSize({ width, height: 900 });
|
||||
await page.goto(`/?dialog-layout&fixture=templates&language=${language}`);
|
||||
await page.getByRole("button", { name: language === "de" ? "Vorlage hinzufügen" : "Add template", exact: true }).click();
|
||||
const dialog = page.getByRole("dialog");
|
||||
await expectDialogFits(dialog);
|
||||
await dialog.locator("input").fill("Eine sehr lange Vorlagenbezeichnung ".repeat(8));
|
||||
await expectDialogFits(dialog);
|
||||
await expect(dialog.locator(".dialog-footer button").last()).toBeEnabled();
|
||||
expect(unexpectedWrites).toEqual([]);
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
import { expect, test } from "@playwright/test";
|
||||
|
||||
for (const initiallyEmpty of [false, true]) {
|
||||
test(`Files ignores a late ${initiallyEmpty ? "space discovery" : "folder listing"} Reload after session change`, async ({ page }) => {
|
||||
let release!: () => void;
|
||||
const heldReload = new Promise<void>((resolve) => { release = resolve; });
|
||||
let holdNextRead = false;
|
||||
let holdingRead = false;
|
||||
const unexpected: string[] = [];
|
||||
await page.route((url) => url.pathname.startsWith("/api/"), async (route) => {
|
||||
const request = route.request();
|
||||
const path = new URL(request.url()).pathname;
|
||||
if (request.method() !== "GET") {
|
||||
unexpected.push(`${request.method()} ${path}`);
|
||||
await route.fulfill({ status: 403, json: { detail: "Fixture forbids writes" } });
|
||||
return;
|
||||
}
|
||||
const next = request.headers().authorization === "Bearer fixture-next-session";
|
||||
const actor = next ? "fixture-next-user" : "fixture-user";
|
||||
const name = next ? "new-session.zip" : "old-session.zip";
|
||||
const delayed = holdNextRead && !next && path === (initiallyEmpty ? "/api/v1/files/spaces" : "/api/v1/files");
|
||||
if (delayed) {
|
||||
holdNextRead = false;
|
||||
holdingRead = true;
|
||||
await heldReload;
|
||||
}
|
||||
if (path === "/api/v1/files/spaces") {
|
||||
await route.fulfill({ json: { spaces: initiallyEmpty && !next && !delayed ? [] : [
|
||||
{ id: `user:${actor}`, label: next ? "New session files" : "Old session files", owner_type: "user", owner_id: actor, space_type: "managed" }
|
||||
] } });
|
||||
} else if (path === "/api/v1/files/folders") {
|
||||
await route.fulfill({ json: { folders: [], total: 0, next_cursor: null } });
|
||||
} else if (path === "/api/v1/files") {
|
||||
await route.fulfill({ json: { files: [{ id: `${actor}-archive`, tenant_id: "fixture-tenant", owner_type: "user", owner_id: actor,
|
||||
display_path: name, filename: name, size_bytes: 42, content_type: "application/zip", checksum_sha256: "a".repeat(64), version_id: "fixture-version",
|
||||
created_at: "2026-01-01T12:00:00Z", updated_at: "2026-01-01T12:00:00Z", audit_relevant: false, shares: [], metadata: {}, deleted_at: null
|
||||
}], total: 1, next_cursor: null } });
|
||||
} else {
|
||||
unexpected.push(`${request.method()} ${path}`);
|
||||
await route.fulfill({ status: 404, json: { detail: "Unconfigured fixture read" } });
|
||||
}
|
||||
});
|
||||
await page.goto("/?files-toolbar&session-switch&language=en");
|
||||
if (initiallyEmpty) await expect(page.getByText("No file spaces available.", { exact: true })).toBeVisible();
|
||||
else await expect(page.locator(".file-row").filter({ hasText: "old-session.zip" })).toBeVisible();
|
||||
const reload = page.locator('[data-interface-id="files.workspace.actions"]').getByRole("button", { name: "Reload", exact: true });
|
||||
await expect(reload).toBeEnabled();
|
||||
holdNextRead = true;
|
||||
await reload.click();
|
||||
await expect.poll(() => holdingRead).toBe(true);
|
||||
await page.getByRole("button", { name: "Switch fixture session", exact: true }).click();
|
||||
await expect(page.locator(".file-row").filter({ hasText: "new-session.zip" })).toBeVisible();
|
||||
const oldResponse = page.waitForResponse((response) => response.url().includes(initiallyEmpty ? "/api/v1/files/spaces" : "/api/v1/files?") && response.request().headers().authorization !== "Bearer fixture-next-session");
|
||||
release();
|
||||
await oldResponse;
|
||||
await expect(page.locator(".file-tree-root")).toHaveText(["New session files"]);
|
||||
await expect(page.locator(".file-row")).toHaveCount(1);
|
||||
await expect(page.locator(".file-row")).toContainText("new-session.zip");
|
||||
await expect(reload).toBeEnabled();
|
||||
expect(unexpected).toEqual([]);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,199 @@
|
||||
import { expect, test, type Page } from "@playwright/test";
|
||||
|
||||
const archive = {
|
||||
id: "fixture-archive", tenant_id: "fixture-tenant", owner_type: "user", owner_id: "fixture-user",
|
||||
display_path: "small-archive.zip", filename: "small-archive.zip", size_bytes: 280,
|
||||
content_type: "application/zip", checksum_sha256: "a".repeat(64), version_id: "fixture-version-7",
|
||||
created_at: "2026-01-01T12:00:00Z", updated_at: "2026-01-01T12:00:00Z", audit_relevant: false,
|
||||
shares: [], metadata: {}, deleted_at: null
|
||||
};
|
||||
|
||||
async function fixtures(page: Page, connector = false) {
|
||||
const reads: string[] = [];
|
||||
const forbidden: string[] = [];
|
||||
const state = { fail: false };
|
||||
await page.route((url) => url.pathname.startsWith("/api/"), async (route) => {
|
||||
const request = route.request();
|
||||
const url = new URL(request.url());
|
||||
const path = url.pathname;
|
||||
const readOnlyPattern = request.method() === "POST" && path === "/api/v1/files/resolve-patterns";
|
||||
if (request.method() !== "GET" && !readOnlyPattern) {
|
||||
forbidden.push(`${request.method()} ${path}`);
|
||||
await route.fulfill({ status: 403, json: { detail: "Fixture refuses all writes" } });
|
||||
return;
|
||||
}
|
||||
reads.push(`${request.method()} ${path}${url.search}`);
|
||||
if (state.fail && (path === "/api/v1/files" || path.endsWith("/browse"))) {
|
||||
await route.fulfill({ status: 503, json: { detail: "Fixture listing temporarily unavailable" } });
|
||||
return;
|
||||
}
|
||||
if (path === "/api/v1/files/spaces") {
|
||||
await route.fulfill({ json: { spaces: [
|
||||
{ id: "user:fixture-user", label: "My files", owner_type: "user", owner_id: "fixture-user", space_type: "managed" },
|
||||
...(connector ? [{ id: "connector:fixture-remote", label: "Remote archive", owner_type: "user", owner_id: "fixture-user", space_type: "connector", connector_space_id: "fixture-remote", connector_profile_id: "fixture-profile", remote_path: "source", provider: "s3", read_only: true, sync_mode: "manual" }] : [])
|
||||
] } });
|
||||
} else if (path === "/api/v1/files/folders") {
|
||||
await route.fulfill({ json: { folders: [], total: 0, next_cursor: null } });
|
||||
} else if (path === "/api/v1/files") {
|
||||
await route.fulfill({ json: { files: [archive], total: 1, next_cursor: null } });
|
||||
} else if (readOnlyPattern) {
|
||||
await route.fulfill({ json: { patterns: [{ pattern: "*.zip", matches: [archive] }], unmatched: [] } });
|
||||
} else if (path === "/api/v1/files/connectors/profiles") {
|
||||
await route.fulfill({ json: { profiles: [] } });
|
||||
} else if (path.endsWith("/fixture-profile/browse")) {
|
||||
const folder = url.searchParams.get("path") ?? "source";
|
||||
await route.fulfill({ json: { profile_id: "fixture-profile", provider: "s3", path: folder, library_id: null,
|
||||
read_only: true, has_more: false, decision: { allowed: true }, items: folder.endsWith("/nested")
|
||||
? [{ kind: "file", name: "remote-letter.pdf", path: "source/nested/remote-letter.pdf", metadata: {}, size_bytes: 42 }]
|
||||
: [{ kind: "folder", name: "nested", path: "source/nested", metadata: {} }]
|
||||
} });
|
||||
} else {
|
||||
forbidden.push(`${request.method()} ${path}`);
|
||||
await route.fulfill({ status: 404, json: { detail: "Unconfigured fixture read" } });
|
||||
}
|
||||
});
|
||||
return { reads, forbidden, state };
|
||||
}
|
||||
|
||||
const header = (page: Page) => page.locator('[data-interface-id="files.workspace.actions"]');
|
||||
const archiveRow = (page: Page) => page.locator(".file-row").filter({ hasText: "small-archive.zip" });
|
||||
|
||||
test("Files global Reload, Create folder and primary Upload remain above both workspace panes", async ({ page }) => {
|
||||
await page.setViewportSize({ width: 1280, height: 900 });
|
||||
const fixture = await fixtures(page);
|
||||
await page.goto("/?files-toolbar&language=en");
|
||||
await expect(archiveRow(page)).toBeVisible();
|
||||
const toolbar = header(page);
|
||||
await expect(toolbar).toHaveAttribute("data-workspace-action-scope", "workspace");
|
||||
await expect(toolbar.getByRole("link", { name: "Open user documentation", exact: true })).toBeVisible();
|
||||
await expect(toolbar.locator('[data-page-action-group="trailing"] button')).toHaveText(["Reload", /Create Folder/, /Upload/]);
|
||||
const [bar, reload, create, upload, tree] = await Promise.all([
|
||||
toolbar.boundingBox(), toolbar.getByRole("button", { name: "Reload", exact: true }).boundingBox(),
|
||||
toolbar.getByRole("button", { name: "Create Folder", exact: true }).boundingBox(),
|
||||
toolbar.getByRole("button", { name: "Upload", exact: true }).boundingBox(), page.locator(".file-tree-panel").boundingBox()
|
||||
]);
|
||||
expect(bar && reload && create && upload && tree).toBeTruthy();
|
||||
expect(reload!.x + reload!.width).toBeLessThanOrEqual(create!.x);
|
||||
expect(create!.x + create!.width).toBeLessThanOrEqual(upload!.x);
|
||||
expect(upload!.x + upload!.width).toBeGreaterThan(bar!.x + bar!.width - 24);
|
||||
expect(bar!.y + bar!.height).toBeLessThanOrEqual(tree!.y + 1);
|
||||
await expect(toolbar.getByRole("button", { name: "Upload", exact: true })).toHaveClass(/primary/);
|
||||
await expect(page.locator('.file-list-sticky [data-workspace-action-scope="workspace"]')).toHaveCount(0);
|
||||
expect(fixture.forbidden).toEqual([]);
|
||||
});
|
||||
|
||||
test("Files selection tools preserve organization, access and confirmed destructive actions", async ({ page }) => {
|
||||
const fixture = await fixtures(page);
|
||||
await page.goto("/?files-toolbar&language=en");
|
||||
await archiveRow(page).click();
|
||||
await expect(page.getByRole("button", { name: "Unpack archive", exact: true })).toBeEnabled();
|
||||
await page.getByRole("button", { name: "Manage selection", exact: true }).click();
|
||||
const dialog = page.getByRole("dialog", { name: "Manage selection", exact: true });
|
||||
await expect(dialog).toContainText("1 file");
|
||||
for (const name of ["Move", "Copy", "Rename", "Manage shares", "Explain access"]) {
|
||||
await expect(dialog.getByRole("button", { name, exact: true })).toBeEnabled();
|
||||
}
|
||||
const remove = dialog.locator('[data-page-action-separation="destructive"]').getByRole("button", { name: "Delete", exact: true });
|
||||
await remove.click();
|
||||
await expect(dialog).toHaveCount(0);
|
||||
await expect(page.getByRole("alertdialog")).toContainText(/delete|Delete/);
|
||||
await page.getByRole("alertdialog").getByRole("button", { name: "Cancel", exact: true }).click();
|
||||
await archiveRow(page).click({ button: "right" });
|
||||
await expect(page.getByRole("menuitem", { name: "Unpack archive", exact: true })).toBeVisible();
|
||||
expect(fixture.forbidden).toEqual([]);
|
||||
});
|
||||
|
||||
test("Files Connections and imports exposes explicit tools without starting synchronization", async ({ page }) => {
|
||||
const fixture = await fixtures(page);
|
||||
await page.goto("/?files-toolbar&language=en");
|
||||
await expect(archiveRow(page)).toBeVisible();
|
||||
const initialReads = fixture.reads.length;
|
||||
await header(page).getByRole("button", { name: "Connections and imports", exact: true }).click();
|
||||
const dialog = page.getByRole("dialog", { name: "Connections and imports", exact: true });
|
||||
await expect(dialog).toContainText("never synchronizes or imports");
|
||||
await expect(dialog.getByRole("button", { name: "Sync", exact: true })).toBeEnabled();
|
||||
await dialog.getByRole("button", { name: "Add Space", exact: true }).click();
|
||||
await expect(page.getByRole("dialog", { name: "Add connector space", exact: true })).toBeVisible();
|
||||
expect(fixture.reads.slice(initialReads).every((read) => read.startsWith("GET "))).toBe(true);
|
||||
expect(fixture.forbidden).toEqual([]);
|
||||
});
|
||||
|
||||
test("Files Reload preserves active pattern and property filters and retains loaded data after failure", async ({ page }) => {
|
||||
const fixture = await fixtures(page);
|
||||
await page.goto("/?files-toolbar&language=en");
|
||||
await expect(archiveRow(page)).toBeVisible();
|
||||
await page.locator(".file-search-row input:not([type=checkbox])").fill("*.zip");
|
||||
await page.locator(".file-search-row").getByRole("button", { name: "Search", exact: true }).click();
|
||||
await page.getByRole("combobox", { name: "Campaign use", exact: true }).selectOption("linked");
|
||||
await page.getByRole("button", { name: "Apply filters", exact: true }).click();
|
||||
await expect(page.getByRole("button", { name: "Clear filters", exact: true })).toBeVisible();
|
||||
const before = fixture.reads.length;
|
||||
await header(page).getByRole("button", { name: "Reload", exact: true }).click();
|
||||
await expect(header(page).getByRole("button", { name: "Reload", exact: true })).toBeEnabled();
|
||||
await expect.poll(() => fixture.reads.slice(before).some((read) => read.startsWith("POST /api/v1/files/resolve-patterns"))).toBe(true);
|
||||
await expect.poll(() => fixture.reads.slice(before).some((read) => read.includes("campaign_usage=linked"))).toBe(true);
|
||||
await expect(header(page).getByRole("button", { name: "Reload", exact: true })).toBeEnabled();
|
||||
await expect(page.locator(".file-search-row input:not([type=checkbox])")).toHaveValue("*.zip");
|
||||
await expect(page.getByRole("combobox", { name: "Campaign use", exact: true })).toHaveValue("linked");
|
||||
fixture.state.fail = true;
|
||||
await header(page).getByRole("button", { name: "Reload", exact: true }).click();
|
||||
await expect(page.getByText(/Fixture listing temporarily unavailable/)).toBeVisible();
|
||||
await expect(archiveRow(page)).toBeVisible();
|
||||
await expect(page.getByRole("button", { name: "Clear filters", exact: true })).toBeVisible();
|
||||
expect(fixture.forbidden).toEqual([]);
|
||||
});
|
||||
|
||||
test("Files reader keeps creation visible with permission reasons and cannot use selected write tools", async ({ page }) => {
|
||||
const fixture = await fixtures(page);
|
||||
await page.goto("/?files-toolbar&read-only&language=en");
|
||||
await expect(archiveRow(page)).toBeVisible();
|
||||
const upload = header(page).getByRole("button", { name: "Upload", exact: true });
|
||||
await expect(upload).toBeDisabled();
|
||||
await header(page).locator(".disabled-action-tooltip").filter({ has: page.getByRole("button", { name: "Upload", exact: true }) }).focus();
|
||||
await expect(page.getByRole("tooltip")).toContainText(/upload permission/i);
|
||||
await expect(header(page).getByRole("button", { name: "Create Folder", exact: true })).toBeDisabled();
|
||||
await archiveRow(page).click();
|
||||
await expect(page.getByRole("button", { name: "Unpack archive", exact: true })).toBeDisabled();
|
||||
await page.getByRole("button", { name: "Manage selection", exact: true }).click();
|
||||
const dialog = page.getByRole("dialog", { name: "Manage selection", exact: true });
|
||||
for (const name of ["Move", "Copy", "Rename", "Manage shares", "Explain access", "Delete"]) {
|
||||
await expect(dialog.getByRole("button", { name, exact: true })).toBeDisabled();
|
||||
}
|
||||
expect(fixture.forbidden).toEqual([]);
|
||||
});
|
||||
|
||||
test("Files connector Reload only re-browses the selected remote folder and retains it on failure", async ({ page }) => {
|
||||
const fixture = await fixtures(page, true);
|
||||
await page.goto("/?files-toolbar&language=en");
|
||||
await expect(archiveRow(page)).toBeVisible();
|
||||
await page.locator(".file-tree-root").filter({ hasText: "Remote archive" }).click();
|
||||
await page.locator(".file-list-panel").getByText("nested", { exact: true }).dblclick();
|
||||
await expect(page.getByText("remote-letter.pdf", { exact: true })).toBeVisible();
|
||||
const before = fixture.reads.length;
|
||||
await header(page).getByRole("button", { name: "Reload", exact: true }).click();
|
||||
await expect(header(page).getByRole("button", { name: "Reload", exact: true })).toBeEnabled();
|
||||
await expect.poll(() => fixture.reads.slice(before)).toEqual(["GET /api/v1/files/connectors/profiles/fixture-profile/browse?path=source%2Fnested"]);
|
||||
await expect(header(page).getByRole("button", { name: "Reload", exact: true })).toBeEnabled();
|
||||
fixture.state.fail = true;
|
||||
await header(page).getByRole("button", { name: "Reload", exact: true }).click();
|
||||
await expect(page.getByText(/Fixture listing temporarily unavailable/)).toBeVisible();
|
||||
await expect(page.getByText("remote-letter.pdf", { exact: true })).toBeVisible();
|
||||
expect(fixture.forbidden).toEqual([]);
|
||||
});
|
||||
|
||||
test("Files grouped tools and creation remain reachable without horizontal overflow on narrow German screens", async ({ page }) => {
|
||||
await page.setViewportSize({ width: 390, height: 844 });
|
||||
const fixture = await fixtures(page);
|
||||
await page.goto("/?files-toolbar&language=de");
|
||||
await expect(archiveRow(page)).toBeVisible();
|
||||
await expect(header(page).getByRole("button", { name: "Hochladen", exact: true })).toBeVisible();
|
||||
await header(page).getByRole("button", { name: "Verbindungen und Importe", exact: true }).click();
|
||||
const dialog = page.getByRole("dialog", { name: "Verbindungen und Importe", exact: true });
|
||||
await expect(dialog).toBeVisible();
|
||||
const overflow = await dialog.evaluate((element) => element.scrollWidth - element.clientWidth);
|
||||
expect(overflow).toBeLessThanOrEqual(1);
|
||||
expect(await page.evaluate(() => document.documentElement.scrollWidth - window.innerWidth)).toBeLessThanOrEqual(1);
|
||||
await page.keyboard.press("Escape");
|
||||
await expect(dialog).toHaveCount(0);
|
||||
expect(fixture.forbidden).toEqual([]);
|
||||
});
|
||||
@@ -0,0 +1,49 @@
|
||||
import { expect, test } from "@playwright/test";
|
||||
|
||||
for (const width of [1440, 900, 390]) {
|
||||
test(`mixed form controls and attachment actions remain aligned at ${width}px`, async ({ page }) => {
|
||||
await page.setViewportSize({ width, height: 1200 });
|
||||
const errors: string[] = [];
|
||||
page.on("pageerror", (error) => errors.push(error.message));
|
||||
await page.route((url) => url.pathname.startsWith("/api/"), (route) => route.abort());
|
||||
await page.goto("/?form-control-layout&language=en");
|
||||
const grid = page.getByTestId("mixed-form-grid");
|
||||
const password = grid.locator('input[type="password"]');
|
||||
const remove = grid.locator(".toggle-switch-row").filter({ hasText: "Remove configured secret" });
|
||||
await expect(remove).toBeVisible();
|
||||
if (width > 1100) {
|
||||
for (const [input, toggle] of [
|
||||
[password, remove.locator(".toggle-switch-track")],
|
||||
[grid.getByRole("textbox", { name: "Wrapped field", exact: true }), grid.locator(".content-grid-item .toggle-switch-track")],
|
||||
[page.getByRole("textbox", { name: "Other setting", exact: true }), page.getByTestId("mixed-form-layout").locator(".toggle-switch-track")]
|
||||
]) {
|
||||
await expect.poll(async () => {
|
||||
const a = await input.boundingBox(), b = await toggle.boundingBox();
|
||||
return a && b ? Math.abs(a.y + a.height / 2 - b.y - b.height / 2) : Infinity;
|
||||
}).toBeLessThan(2);
|
||||
}
|
||||
const long = await grid.getByRole("textbox", { name: "Long label field" }).boundingBox();
|
||||
const short = await grid.getByRole("textbox", { name: "Short label field" }).boundingBox();
|
||||
expect(Math.abs(long!.y - short!.y)).toBeLessThan(2);
|
||||
} else {
|
||||
const field = await password.boundingBox(), toggle = await remove.boundingBox();
|
||||
expect(toggle!.y).toBeGreaterThan(field!.y + field!.height);
|
||||
expect(toggle!.height).toBeLessThanOrEqual(64);
|
||||
}
|
||||
await remove.getByRole("checkbox").focus();
|
||||
await page.keyboard.press("Space");
|
||||
await expect(remove.getByRole("checkbox")).toBeChecked();
|
||||
await password.fill("fixture-only");
|
||||
await expect(remove.getByRole("checkbox")).toBeDisabled();
|
||||
await expect(page.getByTestId("compact-action").getByRole("button")).toHaveCSS("width", "36px");
|
||||
const attachments = page.getByTestId("global-attachments");
|
||||
const add = attachments.getByRole("button", { name: "Add first attachment", exact: true });
|
||||
await expect(add).toHaveCSS("width", "36px");
|
||||
await expect.poll(() => attachments.locator(".data-grid-empty-action-cell").evaluate((node) => node.getBoundingClientRect().width)).toBeLessThanOrEqual(200);
|
||||
await add.click();
|
||||
await expect(attachments.locator(".data-grid-empty-action-cell")).toHaveCount(0);
|
||||
await expect(attachments.getByRole("button", { name: "Add attachment below", exact: true })).toHaveCSS("width", "36px");
|
||||
await expect.poll(() => page.locator("main").evaluate((node) => node.scrollWidth <= node.clientWidth + 1)).toBe(true);
|
||||
expect(errors).toEqual([]);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
import { expect, test, type Page } from "@playwright/test";
|
||||
|
||||
async function installHelp(page: Page, language = "en") {
|
||||
const errors: string[] = [];
|
||||
page.on("pageerror", (error) => errors.push(error.message));
|
||||
await page.route("**/api/v1/docs/context?**", (route) => {
|
||||
const topic = (id: string, title: string, metadata = {}) => ({
|
||||
id, title, source_module_id: "forms", kind: "reference", anchor_id: `topic-${id}`, summary: "Application guidance", body: "Public service application instructions", order: 1,
|
||||
active: true, layer: "configured", target_layer: "configured", links: [], unlocks: [], related_modules: [], area_module_ids: ["forms", "services"],
|
||||
metadata: { tags: ["Application", "Antrag"], ...metadata }, blockers: { modules: [], capabilities: [], scopes: [], configuration: [] }
|
||||
});
|
||||
return route.fulfill({ json: {
|
||||
actor: { documentation_type: new URL(route.request().url()).searchParams.get("type") ?? "user", available_documentation_types: ["user", "admin"] },
|
||||
versions: { supported_versions: [], installed_versions: {}, fallback_policy: "Installed versions" },
|
||||
topic_groups: { system: [], pattern: [], workflow: [], reference: [topic("service", "Service application"), topic("field", "Application field", { parent_topic_id: "service" })] },
|
||||
layers: { configured: { modules: [{ id: "forms", name: language === "de" ? "Formulare" : "Forms" }, { id: "services", name: language === "de" ? "Leistungen" : "Services" }], routes: [], permissions: [] },
|
||||
available: { routes: [] }, evidence: { optional_modules: [], sources: [] } }
|
||||
} });
|
||||
});
|
||||
await page.goto(`/?help-center&language=${language}`);
|
||||
await expect(page.locator(".docs-tree-page").filter({ hasText: "Service application" }).first()).toBeVisible();
|
||||
return errors;
|
||||
}
|
||||
|
||||
test("help keeps repeated topic selection and expansion local to the clicked occurrence", async ({ page }) => {
|
||||
const errors = await installHelp(page);
|
||||
const area = page.locator(".docs-tree-node").filter({ has: page.locator(":scope > .docs-tree-row > .docs-tree-page", { hasText: /^Services$/ }) }).first();
|
||||
await area.locator(":scope > .docs-tree-row > .docs-tree-toggle").click();
|
||||
const repeated = area.locator(".docs-tree-node").filter({ has: page.locator(":scope > .docs-tree-row > .docs-tree-page", { hasText: /^Service application$/ }) }).first();
|
||||
await repeated.locator(":scope > .docs-tree-row > .docs-tree-page").click();
|
||||
await expect(page.locator(".docs-tree-page.is-active")).toHaveCount(1);
|
||||
await expect(repeated.locator(":scope > .docs-tree-row > .docs-tree-page")).toHaveClass(/is-active/);
|
||||
await repeated.locator(":scope > .docs-tree-row > .docs-tree-toggle").click();
|
||||
await expect(page.locator(".docs-tree-page").filter({ hasText: /^Application field$/ })).toHaveCount(1);
|
||||
await page.reload();
|
||||
await expect(area.locator(".docs-tree-page.is-active")).toHaveText("Service application");
|
||||
await expect(page.locator(".docs-tree-page.is-active")).toHaveCount(1);
|
||||
expect(errors).toEqual([]);
|
||||
});
|
||||
|
||||
for (const language of ["en", "de"]) test(`help finds topics from tags and localized area names (${language})`, async ({ page }) => {
|
||||
const errors = await installHelp(page, language);
|
||||
const search = page.getByRole("searchbox");
|
||||
await search.fill(language === "de" ? "leistungen antrag" : "services application");
|
||||
const results = page.getByRole("region", { name: language === "de" ? "Passende Hilfethemen" : "Matching help topics" });
|
||||
await expect(results.getByRole("option", { name: "Service application", exact: true })).toHaveCount(1);
|
||||
await expect(results.getByRole("option", { name: "Application field", exact: true })).toHaveCount(1);
|
||||
await search.fill("not-found-tag");
|
||||
await expect(results.getByRole("option")).toHaveCount(0);
|
||||
await search.fill("antrag");
|
||||
await results.getByRole("option", { name: "Service application", exact: true }).click();
|
||||
await expect(search).toHaveValue("");
|
||||
await expect(page.locator(".docs-page-main h2")).toHaveText("Service application");
|
||||
await expect(page.locator(".docs-tree-page.is-active")).toHaveCount(1);
|
||||
expect(errors).toEqual([]);
|
||||
});
|
||||
|
||||
test("help tag dropdown shares list filtering and all/none behavior", async ({ page }) => {
|
||||
const errors = await installHelp(page);
|
||||
await page.getByRole("button", { name: "Areas and tags", exact: true }).click();
|
||||
const filter = page.getByRole("dialog", { name: "Areas and tags", exact: true });
|
||||
await filter.getByRole("button", { name: /clear all|deselect all|select none/i }).click();
|
||||
const results = page.getByRole("region", { name: "Matching help topics" });
|
||||
await expect(results.getByRole("option")).toHaveCount(0);
|
||||
await filter.getByRole("checkbox", { name: "Services", exact: true }).check();
|
||||
await expect(results.getByRole("option")).toHaveCount(2);
|
||||
await filter.getByRole("button", { name: "Select all", exact: true }).click();
|
||||
await expect(results).toHaveCount(0);
|
||||
await page.keyboard.press("Escape");
|
||||
await expect(filter).toHaveCount(0);
|
||||
expect(errors).toEqual([]);
|
||||
});
|
||||
@@ -0,0 +1,215 @@
|
||||
import { expect, test, type Page } from "@playwright/test";
|
||||
|
||||
type Policy = {
|
||||
smtp_credentials?: { inherit?: boolean | null };
|
||||
imap_credentials?: { inherit?: boolean | null };
|
||||
allow_lower_level_limits?: Record<string, boolean>;
|
||||
[key: string]: unknown;
|
||||
};
|
||||
|
||||
async function installPolicy(page: Page, options: { parent?: Policy | null; local?: Policy; failFirst?: boolean; failReadOnce?: boolean; holdSave?: Promise<void> } = {}) {
|
||||
let current = options.local ?? {};
|
||||
const parent = options.parent === null ? null : { smtp_credentials: { inherit: false }, imap_credentials: { inherit: true }, ...options.parent };
|
||||
const writes: Policy[] = [];
|
||||
const unexpected: string[] = [];
|
||||
let reads = 0;
|
||||
function response() {
|
||||
const effective: Policy = { allow_user_profiles: true, allow_group_profiles: true, allow_campaign_profiles: true, ...parent, ...current };
|
||||
for (const protocol of ["smtp", "imap"] as const) {
|
||||
const key = `${protocol}_credentials` as const;
|
||||
const locked = parent?.allow_lower_level_limits?.[`${key}.inherit`] === false;
|
||||
effective[key] = { inherit: (!locked ? current[key]?.inherit : null) ?? parent?.[key]?.inherit ?? true };
|
||||
}
|
||||
return { policy: current, parent_policy: parent, effective_policy: effective, effective_policy_sources: [
|
||||
{ scope_type: "system", label: "System", path: "system", applied_fields: [], policy: parent ?? {} },
|
||||
{ scope_type: "tenant", label: "Tenant", path: "tenant", applied_fields: [], policy: current }
|
||||
] };
|
||||
}
|
||||
await page.route((url) => url.pathname.startsWith("/api/"), async (route) => {
|
||||
const request = route.request();
|
||||
if (new URL(request.url()).pathname.startsWith("/api/v1/mail/policies/")) {
|
||||
if (request.method() === "GET") {
|
||||
reads += 1;
|
||||
if (options.failReadOnce && reads === 1) {
|
||||
await route.fulfill({ status: 503, json: { detail: "Synthetic policy load failed" } }); return;
|
||||
}
|
||||
await route.fulfill({ json: response() }); return;
|
||||
}
|
||||
if (request.method() === "PUT") {
|
||||
const submitted = request.postDataJSON().policy as Policy;
|
||||
writes.push(submitted);
|
||||
if (options.holdSave) await options.holdSave;
|
||||
if (options.failFirst && writes.length === 1) {
|
||||
await route.fulfill({ status: 503, json: { detail: "Synthetic policy write failed" } }); return;
|
||||
}
|
||||
current = submitted;
|
||||
await route.fulfill({ json: response() }); return;
|
||||
}
|
||||
}
|
||||
unexpected.push(`${request.method()} ${new URL(request.url()).pathname}`);
|
||||
await route.fulfill({ status: 403, json: { detail: "Unexpected fixture request" } });
|
||||
});
|
||||
return { writes, unexpected, get reads() { return reads; } };
|
||||
}
|
||||
|
||||
function credentialRows(page: Page) {
|
||||
const section = page.getByTestId("mail-credential-policy");
|
||||
return {
|
||||
section,
|
||||
smtp: section.locator(".policy-row").filter({ has: page.locator('select[aria-label="SMTP credential selection"]') }),
|
||||
imap: section.locator(".policy-row").filter({ has: page.locator('select[aria-label="IMAP credential selection"]') })
|
||||
};
|
||||
}
|
||||
|
||||
test("Mail credential policy exposes inherited/effective choices and persists stable override keys", async ({ page }) => {
|
||||
page.on("pageerror", (error) => { throw error; });
|
||||
const fixture = await installPolicy(page);
|
||||
await page.goto("/?mail-credential-policy&language=en");
|
||||
const { smtp, imap } = credentialRows(page);
|
||||
await expect(smtp.locator("select")).toHaveValue("inherit");
|
||||
await expect(smtp.locator(".policy-effective-value")).toContainText("Require explicit Mail credential");
|
||||
// An inherited false does not itself lock the child: only the separate
|
||||
// lower-level limit may prevent choosing an inherited default credential.
|
||||
await smtp.locator("select").selectOption("profile");
|
||||
await imap.locator("select").selectOption("explicit");
|
||||
await smtp.getByRole("checkbox", { name: "Allow override" }).focus();
|
||||
await page.keyboard.press("Space");
|
||||
const wildcard = page.locator(".mail-policy-pattern-row").filter({ hasText: "SMTP hostnames" });
|
||||
await wildcard.getByRole("checkbox", { name: "Whitelist", exact: true }).focus();
|
||||
await page.keyboard.press("Space");
|
||||
const campaignRow = page.locator(".mail-policy-row").filter({ hasText: "Campaign-scoped profiles" });
|
||||
await campaignRow.getByRole("checkbox", { name: "Allow override" }).focus();
|
||||
await page.keyboard.press("Space");
|
||||
await page.getByRole("button", { name: "Save policy", exact: true }).click();
|
||||
await expect.poll(() => fixture.writes.length).toBe(1);
|
||||
expect(fixture.writes[0].smtp_credentials).toEqual({ inherit: true });
|
||||
expect(fixture.writes[0].imap_credentials).toEqual({ inherit: false });
|
||||
expect(fixture.writes[0].allow_lower_level_limits).toMatchObject({
|
||||
"smtp_credentials.inherit": false, "whitelist.smtp_hosts": false, "allow_campaign_profiles": false
|
||||
});
|
||||
expect(Object.keys(fixture.writes[0].allow_lower_level_limits ?? {}).some((key) => key.startsWith("i18n:"))).toBe(false);
|
||||
await expect(page.getByRole("button", { name: "Save policy", exact: true })).toBeDisabled();
|
||||
expect(fixture.unexpected).toEqual([]);
|
||||
});
|
||||
|
||||
test("ancestor credential locks cannot be bypassed or re-enabled and stale local overrides are cleared on save", async ({ page }) => {
|
||||
const fixture = await installPolicy(page, {
|
||||
parent: { allow_lower_level_limits: { "smtp_credentials.inherit": false } },
|
||||
local: { smtp_credentials: { inherit: true }, allow_lower_level_limits: { "smtp_credentials.inherit": true } }
|
||||
});
|
||||
await page.goto("/?mail-credential-policy&language=en");
|
||||
const { smtp, imap, section } = credentialRows(page);
|
||||
await expect(smtp.locator("select")).toHaveValue("inherit");
|
||||
await expect(smtp.locator("select")).toBeDisabled();
|
||||
await expect(smtp.getByRole("checkbox", { name: "Allow override" })).toBeDisabled();
|
||||
await expect(smtp.getByRole("checkbox", { name: "Allow override" })).not.toBeChecked();
|
||||
await expect(section).toContainText("An ancestor has locked credential selection.");
|
||||
await imap.locator("select").selectOption("explicit");
|
||||
await page.getByRole("button", { name: "Save policy", exact: true }).click();
|
||||
await expect.poll(() => fixture.writes.length).toBe(1);
|
||||
expect(fixture.writes[0].smtp_credentials).toEqual({ inherit: null });
|
||||
expect(fixture.writes[0].allow_lower_level_limits?.["smtp_credentials.inherit"]).toBeUndefined();
|
||||
expect(fixture.unexpected).toEqual([]);
|
||||
});
|
||||
|
||||
test("system defaults are concrete and campaign policy has no lower-level override controls", async ({ page }) => {
|
||||
const fixture = await installPolicy(page, { parent: null });
|
||||
await page.goto("/?mail-credential-policy&scope=system&language=en");
|
||||
let { smtp, imap } = credentialRows(page);
|
||||
await expect(smtp.locator("select")).toHaveValue("profile");
|
||||
await expect(smtp.locator('option[value="inherit"]')).toHaveCount(0);
|
||||
await smtp.locator("select").selectOption("explicit");
|
||||
await page.getByRole("button", { name: "Save policy", exact: true }).click();
|
||||
await expect.poll(() => fixture.writes.length).toBe(1);
|
||||
expect(fixture.writes[0].smtp_credentials).toEqual({ inherit: false });
|
||||
expect(fixture.writes[0].imap_credentials).toEqual({ inherit: true });
|
||||
expect(fixture.writes[0].allow_lower_level_limits).toHaveProperty("allow_campaign_profiles", true);
|
||||
await page.goto("/?mail-credential-policy&scope=campaign&language=en");
|
||||
({ smtp, imap } = credentialRows(page));
|
||||
await expect(smtp.getByRole("checkbox")).toHaveCount(0);
|
||||
await expect(imap.getByRole("checkbox")).toHaveCount(0);
|
||||
await smtp.locator("select").selectOption("profile");
|
||||
await page.getByRole("button", { name: "Save policy", exact: true }).click();
|
||||
await expect.poll(() => fixture.writes.length).toBe(2);
|
||||
expect(fixture.writes[1].allow_lower_level_limits).toEqual({});
|
||||
expect(fixture.unexpected).toEqual([]);
|
||||
});
|
||||
|
||||
test("read-only and workflow-locked policy scopes cannot change credential selection", async ({ page }) => {
|
||||
const fixture = await installPolicy(page);
|
||||
for (const blocker of ["read-only", "locked"]) {
|
||||
await page.goto(`/?mail-credential-policy&language=en&${blocker}`);
|
||||
const { smtp, imap } = credentialRows(page);
|
||||
await expect(smtp.locator("select")).toBeDisabled();
|
||||
await expect(imap.locator("select")).toBeDisabled();
|
||||
await expect(smtp.getByRole("checkbox", { name: "Allow override" })).toBeDisabled();
|
||||
await expect(page.getByRole("button", { name: "Save policy", exact: true })).toBeDisabled();
|
||||
}
|
||||
expect(fixture.writes).toEqual([]);
|
||||
expect(fixture.unexpected).toEqual([]);
|
||||
});
|
||||
|
||||
test("successful policy saves remain successful when a dependent refresh fails", async ({ page }) => {
|
||||
const fixture = await installPolicy(page);
|
||||
await page.goto("/?mail-credential-policy&language=en&refresh-failure");
|
||||
await credentialRows(page).smtp.locator("select").selectOption("profile");
|
||||
await page.getByRole("button", { name: "Save policy", exact: true }).click();
|
||||
await expect(page.getByRole("alert")).toContainText("Mail policy was saved, but refreshing dependent data failed");
|
||||
await expect(page.locator(".alert.danger")).toHaveCount(0);
|
||||
await expect(page.locator(".alert.success")).toContainText("Mail profile policy saved");
|
||||
await expect(page.getByRole("button", { name: "Save policy", exact: true })).toBeDisabled();
|
||||
expect(fixture.writes).toHaveLength(1);
|
||||
await page.getByRole("button", { name: "Reload", exact: true }).click();
|
||||
await expect.poll(() => fixture.reads).toBeGreaterThan(1);
|
||||
await expect(credentialRows(page).smtp.locator("select")).toHaveValue("profile");
|
||||
expect(fixture.writes).toHaveLength(1);
|
||||
expect(fixture.unexpected).toEqual([]);
|
||||
});
|
||||
|
||||
test("failed policy writes retain the draft and require an explicit retry", async ({ page }) => {
|
||||
let finish!: () => void;
|
||||
const fixture = await installPolicy(page, { failFirst: true, holdSave: new Promise<void>((resolve) => { finish = resolve; }) });
|
||||
await page.goto("/?mail-credential-policy&language=en");
|
||||
const { smtp } = credentialRows(page);
|
||||
await smtp.locator("select").selectOption("profile");
|
||||
await page.getByRole("button", { name: "Save policy", exact: true }).click();
|
||||
await expect.poll(() => fixture.writes.length).toBe(1);
|
||||
await expect(smtp.locator("select")).toBeDisabled();
|
||||
finish();
|
||||
await expect(page.locator(".alert.danger")).toContainText("Synthetic policy write failed");
|
||||
await expect(smtp.locator("select")).toHaveValue("profile");
|
||||
await expect(page.getByRole("button", { name: "Save policy", exact: true })).toBeEnabled();
|
||||
expect(fixture.writes).toHaveLength(1);
|
||||
await page.getByRole("button", { name: "Save policy", exact: true }).click();
|
||||
await expect(page.getByRole("button", { name: "Save policy", exact: true })).toBeDisabled();
|
||||
expect(fixture.writes).toHaveLength(2);
|
||||
expect(fixture.writes[1]).toEqual(fixture.writes[0]);
|
||||
expect(fixture.unexpected).toEqual([]);
|
||||
});
|
||||
|
||||
test("failed policy loads cannot enable editing an unknown ancestor policy", async ({ page }) => {
|
||||
const fixture = await installPolicy(page, { failReadOnce: true });
|
||||
await page.goto("/?mail-credential-policy&language=en");
|
||||
await expect(page.locator(".alert.danger")).toContainText("Synthetic policy load failed");
|
||||
await expect(credentialRows(page).smtp.locator("select")).toBeDisabled();
|
||||
await expect(page.getByRole("button", { name: "Save policy", exact: true })).toBeDisabled();
|
||||
await page.getByRole("button", { name: "Reload", exact: true }).click();
|
||||
await expect(credentialRows(page).smtp.locator("select")).toBeEnabled();
|
||||
expect(fixture.writes).toEqual([]);
|
||||
expect(fixture.unexpected).toEqual([]);
|
||||
});
|
||||
|
||||
test("credential selection choices and explanations are available in German", async ({ page }) => {
|
||||
const fixture = await installPolicy(page);
|
||||
await page.goto("/?mail-credential-policy&language=de");
|
||||
const section = page.getByTestId("mail-credential-policy");
|
||||
await expect(section.getByRole("heading", { name: "Auswahl der Zugangsdaten", exact: true })).toBeVisible();
|
||||
const smtp = section.getByRole("combobox", { name: "SMTP-Zugangsdaten auswählen", exact: true });
|
||||
await expect(smtp).toBeEnabled();
|
||||
await expect(smtp.locator('option[value="inherit"]')).toHaveText("Richtlinie vom übergeordneten Bereich erben");
|
||||
await expect(smtp.locator('option[value="profile"]')).toHaveText("Standard-Zugangsdaten des Profils zulassen");
|
||||
await expect(smtp.locator('option[value="explicit"]')).toHaveText("Ausdrückliche Mail-Zugangsdaten verlangen");
|
||||
await expect(section).toContainText("Beide Optionen belassen Geheimnisse in Mail");
|
||||
expect(fixture.writes).toEqual([]);
|
||||
expect(fixture.unexpected).toEqual([]);
|
||||
});
|
||||
@@ -0,0 +1,48 @@
|
||||
import { expect, test } from "@playwright/test";
|
||||
|
||||
test("Mail synthetic parent labels select without opening nonexistent folders or changing expansion", async ({ page }) => {
|
||||
const errors: string[] = [];
|
||||
const providerFolders: string[] = [];
|
||||
const writes: string[] = [];
|
||||
page.on("pageerror", (error) => errors.push(error.message));
|
||||
await page.route(url => url.pathname.startsWith("/api/"), async route => {
|
||||
const request = route.request();
|
||||
const url = new URL(request.url());
|
||||
if (request.method() !== "GET") writes.push(url.pathname);
|
||||
let body: unknown = {};
|
||||
if (url.pathname === "/api/v1/mail/profiles") body = { profiles: [{
|
||||
id: "tree-profile", name: "Fixture mailbox", is_active: true, scope_type: "tenant",
|
||||
imap: { enabled: true, host: "imap.example.test", port: 993, security: "ssl", folder_mappings: { inbox: "INBOX" } }
|
||||
}] };
|
||||
else if (url.pathname.endsWith("/mailbox/bootstrap")) body = {
|
||||
folder: "INBOX", folders: { ok: true, folders: [{ name: "INBOX", flags: [] }, { name: "Archive/2026", flags: [] }] },
|
||||
messages: { ok: true, folder: "INBOX", messages: [], total_count: 0 }
|
||||
};
|
||||
else if (url.pathname.endsWith("/mailbox/messages")) {
|
||||
providerFolders.push(url.searchParams.get("folder") ?? "");
|
||||
body = { ok: true, folder: url.searchParams.get("folder"), messages: [], total_count: 0 };
|
||||
}
|
||||
return route.fulfill({ json: body });
|
||||
});
|
||||
await page.goto("/?mail-folder-explorer&language=en&theme=light");
|
||||
const archive = page.locator('.explorer-tree-node').filter({ hasText: /^Archive$/ });
|
||||
const archiveRow = page.locator('.explorer-tree-node-wrap').filter({ has: archive });
|
||||
const toggle = archiveRow.locator(':scope > .explorer-tree-toggle');
|
||||
await expect(archive).toBeEnabled();
|
||||
await expect(toggle).toHaveAttribute("aria-expanded", "false");
|
||||
const initialReads = providerFolders.length;
|
||||
await archive.click();
|
||||
await expect(archive).toHaveAttribute("aria-current", "true");
|
||||
await expect(toggle).toHaveAttribute("aria-expanded", "false");
|
||||
await expect(page.getByText("This grouping contains mailbox folders.", { exact: false })).toBeVisible();
|
||||
expect(providerFolders).toHaveLength(initialReads);
|
||||
await toggle.click();
|
||||
await expect(toggle).toHaveAttribute("aria-expanded", "true");
|
||||
await archive.click();
|
||||
await expect(toggle).toHaveAttribute("aria-expanded", "true");
|
||||
await page.locator('.explorer-tree-node').filter({ hasText: /^2026$/ }).click();
|
||||
await expect.poll(() => providerFolders.includes("Archive/2026")).toBe(true);
|
||||
expect(providerFolders).not.toContain("Archive");
|
||||
expect(errors).toEqual([]);
|
||||
expect(writes).toEqual([]);
|
||||
});
|
||||
@@ -0,0 +1,424 @@
|
||||
import { expect, test, type Page, type Route } from "@playwright/test";
|
||||
|
||||
function deferred() {
|
||||
let release!: () => void;
|
||||
const promise = new Promise<void>(resolve => { release = resolve; });
|
||||
return { promise, release };
|
||||
}
|
||||
|
||||
function profile(id: string, protocol = "imap") {
|
||||
return {
|
||||
id, name: `${id} mailbox`, is_active: true, scope_type: "tenant",
|
||||
...(protocol === "imap" ? { imap: { enabled: true, host: "imap.example.test", port: 993, security: "ssl", folder_mappings: { inbox: "INBOX" } } }
|
||||
: { servers: [{ id: `${id}-jmap`, protocol: "jmap", is_active: true, is_default: true, config: { session_url: "https://jmap.example.test/session" } }] })
|
||||
};
|
||||
}
|
||||
|
||||
async function mockMailbox(page: Page) {
|
||||
const state = {
|
||||
profiles: [profile("Alpha"), profile("Beta")],
|
||||
reads: [] as URL[], writes: [] as string[], errors: [] as string[],
|
||||
failProfiles: false, failBootstrap: false, failDetail: false,
|
||||
intercept: null as null | ((url: URL, route: Route) => Promise<boolean>),
|
||||
releasedReads: 0
|
||||
};
|
||||
page.on("pageerror", error => state.errors.push(error.message));
|
||||
await page.route(url => url.pathname.startsWith("/api/"), async route => {
|
||||
const request = route.request();
|
||||
const url = new URL(request.url());
|
||||
if (request.method() !== "GET") {
|
||||
state.writes.push(url.pathname);
|
||||
return route.fulfill({ status: 405, json: { detail: "Fixture forbids mailbox writes" } });
|
||||
}
|
||||
state.reads.push(url);
|
||||
if (state.intercept && await state.intercept(url, route)) return;
|
||||
let body: unknown = {};
|
||||
if (url.pathname === "/api/v1/mail/profiles") {
|
||||
if (state.failProfiles) return route.fulfill({ status: 503, json: { detail: "Mailbox profile refresh unavailable" } });
|
||||
body = { profiles: state.profiles };
|
||||
} else if (url.pathname.endsWith("/mailbox/bootstrap")) {
|
||||
if (state.failBootstrap) return route.fulfill({ status: 503, json: { detail: "Mailbox refresh unavailable" } });
|
||||
body = bootstrap(url);
|
||||
} else if (url.pathname.endsWith("/mailbox/folders")) body = folderCatalogue();
|
||||
else if (url.pathname.endsWith("/mailbox/messages")) body = messageIndex(url);
|
||||
else if (/\/mailbox\/messages\/[^/]+$/.test(url.pathname)) {
|
||||
if (state.failDetail) return route.fulfill({ status: 503, json: { detail: "Message preview refresh unavailable" } });
|
||||
body = messageDetail(url);
|
||||
} else if (url.pathname.endsWith("/address-write-targets")) body = { available: false, targets: [] };
|
||||
return route.fulfill({ json: body });
|
||||
});
|
||||
return state;
|
||||
}
|
||||
|
||||
function folderCatalogue() {
|
||||
return { ok: true, folders: [{ name: "INBOX", flags: [] }, { name: "Archive/2026", flags: [] }] };
|
||||
}
|
||||
|
||||
function messageIndex(url: URL) {
|
||||
const id = url.pathname.split("/")[5];
|
||||
const folder = url.searchParams.get("folder") || "INBOX";
|
||||
const offset = Number(url.searchParams.get("offset") ?? 0);
|
||||
const limit = Number(url.searchParams.get("limit") ?? 10);
|
||||
return {
|
||||
profile_id: id, folder, total_count: 24, offset, limit,
|
||||
next_cursor: "next-fixture-cursor", from_cache: false,
|
||||
messages: Array.from({ length: Math.max(0, Math.min(limit, 24 - offset)) }, (_, index) => ({
|
||||
uid: String(offset + index + 1), folder, subject: `${id} message ${offset + index + 1}`,
|
||||
from_header: "Fixture sender <fixture@example.test>", to_header: "Reader <reader@example.test>",
|
||||
flags: [], size_bytes: 1024, date: "2026-09-01T12:00:00Z"
|
||||
}))
|
||||
};
|
||||
}
|
||||
|
||||
function bootstrap(url: URL) {
|
||||
const messages = messageIndex(url);
|
||||
return { profile_id: messages.profile_id, folder: messages.folder, folders: folderCatalogue(), messages };
|
||||
}
|
||||
|
||||
function messageDetail(url: URL) {
|
||||
const parts = url.pathname.split("/");
|
||||
const uid = parts[parts.length - 1];
|
||||
const summary = messageIndex(new URL(url.href.replace(/offset=[^&]*/, "offset=0"))).messages[0];
|
||||
return { message: { ...summary, uid, subject: `${url.pathname.split("/")[5]} message ${uid}`, body_text: `Fixture body ${url.pathname.split("/")[5]} ${uid}`, headers: {}, attachments: [] } };
|
||||
}
|
||||
|
||||
const workspaceBar = (page: Page) => page.locator('[data-workspace-action-scope="workspace"]');
|
||||
const reload = (page: Page) => workspaceBar(page).locator('[data-page-action-slot="reload"] button');
|
||||
const mailboxRows = (page: Page) => page.locator(".mailbox-message-row");
|
||||
const mainMailboxReads = (reads: URL[]) => reads.filter(url => /\/mail\/profiles(?:$|\/[^/]+\/mailbox\/)/.test(url.pathname));
|
||||
|
||||
test("Mail has one persistent right-aligned Reload, including empty profiles, and recovers when a profile becomes available", async ({ page }) => {
|
||||
const state = await mockMailbox(page);
|
||||
state.profiles = [];
|
||||
await page.setViewportSize({ width: 1280, height: 900 });
|
||||
await page.goto("/?mail-toolbar&language=en&theme=light");
|
||||
await expect(reload(page)).toBeEnabled();
|
||||
await expect(workspaceBar(page)).toHaveCount(1);
|
||||
await expect(page.getByRole("combobox", { name: "Mailbox profile" })).toBeDisabled();
|
||||
await expect(page.getByRole("button", { name: "Mailbox tools", exact: true })).toBeVisible();
|
||||
await expect(page.getByRole("button", { name: /Refresh (available profiles|folders only|messages only)/ })).toHaveCount(0);
|
||||
const [barBox, reloadBox] = await Promise.all([workspaceBar(page).boundingBox(), reload(page).boundingBox()]);
|
||||
expect(reloadBox!.x).toBeGreaterThan(barBox!.x + barBox!.width / 2);
|
||||
expect(barBox!.x + barBox!.width - reloadBox!.x - reloadBox!.width).toBeLessThanOrEqual(20);
|
||||
expect(barBox!.y).toBeLessThan(20);
|
||||
state.profiles = [profile("Alpha")];
|
||||
await reload(page).click();
|
||||
await expect(mailboxRows(page)).toHaveCount(10);
|
||||
await expect(page.getByRole("combobox", { name: "Mailbox profile" })).toHaveValue("Alpha");
|
||||
expect(state.errors).toEqual([]);
|
||||
expect(state.writes).toEqual([]);
|
||||
});
|
||||
|
||||
test("Mail Reload coherently refreshes catalogue, current IMAP page and selected preview without resetting expansion", async ({ page }) => {
|
||||
const state = await mockMailbox(page);
|
||||
await page.goto("/?mail-toolbar&language=en&theme=light");
|
||||
await expect(mailboxRows(page)).toHaveCount(10);
|
||||
expect((await page.locator(".mailbox-message-scroll").boundingBox())!.height).toBeGreaterThan(90);
|
||||
await page.getByRole("button", { name: "Next page", exact: true }).click();
|
||||
await expect(mailboxRows(page).first()).toContainText("Alpha message 11");
|
||||
await mailboxRows(page).first().click();
|
||||
await expect(page.getByText("Fixture body Alpha 11", { exact: true })).toBeVisible();
|
||||
const group = page.locator(".explorer-tree-node-wrap").filter({ has: page.locator(".explorer-tree-node").filter({ hasText: /^Archive$/ }) });
|
||||
const toggle = group.locator(":scope > .explorer-tree-toggle");
|
||||
await toggle.click();
|
||||
const before = state.reads.length;
|
||||
await reload(page).click();
|
||||
await expect(reload(page)).toBeEnabled();
|
||||
await expect(mailboxRows(page).first()).toContainText("Alpha message 11");
|
||||
await expect(mailboxRows(page).first()).toHaveClass(/is-selected/);
|
||||
await expect(page.getByText("Fixture body Alpha 11", { exact: true })).toBeVisible();
|
||||
await expect(toggle).toHaveAttribute("aria-expanded", "true");
|
||||
const reads = mainMailboxReads(state.reads.slice(before));
|
||||
expect(reads.map(url => url.pathname)).toEqual(["/api/v1/mail/profiles", "/api/v1/mail/profiles/Alpha/mailbox/bootstrap", "/api/v1/mail/profiles/Alpha/mailbox/messages/11"]);
|
||||
expect(reads[1].searchParams.get("offset")).toBe("10");
|
||||
expect(reads[1].searchParams.get("refresh")).toBe("true");
|
||||
expect(reads[1].searchParams.get("folder")).toBe("INBOX");
|
||||
expect(state.errors).toEqual([]);
|
||||
expect(state.writes).toEqual([]);
|
||||
});
|
||||
|
||||
test("Mail refresh failures preserve loaded index and preview, disclose failure, and leave recovery available", async ({ page }) => {
|
||||
const state = await mockMailbox(page);
|
||||
await page.goto("/?mail-toolbar&language=en&theme=light");
|
||||
await expect(mailboxRows(page)).toHaveCount(10);
|
||||
await mailboxRows(page).first().click();
|
||||
await expect(page.getByText("Fixture body Alpha 1", { exact: true })).toBeVisible();
|
||||
for (const failure of ["failProfiles", "failBootstrap", "failDetail"] as const) {
|
||||
state[failure] = true;
|
||||
await reload(page).click();
|
||||
await expect(reload(page)).toBeEnabled();
|
||||
await expect(workspaceBar(page)).toHaveAttribute("data-page-refresh-state", "reload-failed");
|
||||
await expect(mailboxRows(page)).toHaveCount(10);
|
||||
await expect(mailboxRows(page).first()).toHaveClass(/is-selected/);
|
||||
await expect(page.getByText("Fixture body Alpha 1", { exact: true })).toBeVisible();
|
||||
state[failure] = false;
|
||||
await reload(page).click();
|
||||
await expect(workspaceBar(page)).toHaveAttribute("data-page-refresh-state", "current");
|
||||
await expect(reload(page)).toBeEnabled();
|
||||
}
|
||||
expect(state.errors).toEqual([]);
|
||||
expect(state.writes).toEqual([]);
|
||||
});
|
||||
|
||||
test("Mail grouping Reload refreshes only profiles and folders, preserving synthetic selection and collapsed state", async ({ page }) => {
|
||||
const state = await mockMailbox(page);
|
||||
await page.goto("/?mail-toolbar&language=en&theme=light");
|
||||
const group = page.locator(".explorer-tree-node").filter({ hasText: /^Archive$/ });
|
||||
await expect(group).toBeEnabled();
|
||||
await group.click();
|
||||
const before = state.reads.length;
|
||||
await reload(page).click();
|
||||
await expect(reload(page)).toBeEnabled();
|
||||
await expect(group).toHaveAttribute("aria-current", "true");
|
||||
await expect(page.locator(".explorer-tree-node-wrap").filter({ has: group }).locator(":scope > .explorer-tree-toggle")).toHaveAttribute("aria-expanded", "false");
|
||||
await expect(mailboxRows(page)).toHaveCount(0);
|
||||
expect(mainMailboxReads(state.reads.slice(before)).map(url => url.pathname)).toEqual(["/api/v1/mail/profiles", "/api/v1/mail/profiles/Alpha/mailbox/folders"]);
|
||||
expect(state.errors).toEqual([]);
|
||||
expect(state.writes).toEqual([]);
|
||||
});
|
||||
|
||||
test("Mail Escape while Reload is pending does not reopen the dismissed preview", async ({ page }) => {
|
||||
const state = await mockMailbox(page);
|
||||
await page.goto("/?mail-toolbar&language=en&theme=light");
|
||||
await expect(mailboxRows(page)).toHaveCount(10);
|
||||
await mailboxRows(page).first().click();
|
||||
await expect(page.getByText("Fixture body Alpha 1", { exact: true })).toBeVisible();
|
||||
const held = deferred();
|
||||
let started = false;
|
||||
state.intercept = async (url, route) => {
|
||||
if (url.pathname.endsWith("/mailbox/bootstrap") && url.searchParams.get("refresh")) {
|
||||
started = true;
|
||||
await held.promise;
|
||||
await route.fulfill({ json: bootstrap(url) });
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
};
|
||||
const before = state.reads.length;
|
||||
await reload(page).click();
|
||||
await expect.poll(() => started).toBe(true);
|
||||
await page.keyboard.press("Escape");
|
||||
held.release();
|
||||
await expect(reload(page)).toBeEnabled();
|
||||
await expect(page.getByText("Fixture body Alpha 1", { exact: true })).toHaveCount(0);
|
||||
await expect(page.locator(".mailbox-message-row.is-selected")).toHaveCount(0);
|
||||
expect(state.reads.slice(before).filter(url => /\/mailbox\/messages\/[^/]+$/.test(url.pathname))).toHaveLength(0);
|
||||
expect(state.errors).toEqual([]);
|
||||
expect(state.writes).toEqual([]);
|
||||
});
|
||||
|
||||
test("Mail failed pagination keeps retained rows labelled with their committed page and size", async ({ page }) => {
|
||||
const state = await mockMailbox(page);
|
||||
await page.goto("/?mail-toolbar&language=en&theme=light");
|
||||
await expect(mailboxRows(page)).toHaveCount(10);
|
||||
state.intercept = async (url, route) => {
|
||||
if (url.pathname.endsWith("/mailbox/messages")) {
|
||||
await route.fulfill({ status: 503, json: { detail: "Mailbox page unavailable" } });
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
};
|
||||
const before = state.reads.length;
|
||||
await page.getByRole("button", { name: "Next page", exact: true }).click();
|
||||
await expect(reload(page)).toBeEnabled();
|
||||
await expect(workspaceBar(page)).toHaveAttribute("data-page-refresh-state", "reload-failed");
|
||||
await expect(page.locator(".data-grid-page-controls")).toContainText("Page 1 of 3");
|
||||
await expect(mailboxRows(page).first()).toContainText("Alpha message 1");
|
||||
expect(mainMailboxReads(state.reads.slice(before))).toHaveLength(1);
|
||||
await page.getByRole("combobox", { name: "Rows per page" }).selectOption("25");
|
||||
await expect(reload(page)).toBeEnabled();
|
||||
await expect(page.getByRole("combobox", { name: "Rows per page" })).toHaveValue("10");
|
||||
await expect(page.locator(".data-grid-page-controls")).toContainText("Page 1 of 3");
|
||||
await expect(mailboxRows(page)).toHaveCount(10);
|
||||
expect(mainMailboxReads(state.reads.slice(before))).toHaveLength(2);
|
||||
expect(state.errors).toEqual([]);
|
||||
expect(state.writes).toEqual([]);
|
||||
});
|
||||
|
||||
test("Mail Reload reauthorizes profile availability and never keeps a removed account selected", async ({ page }) => {
|
||||
const state = await mockMailbox(page);
|
||||
await page.goto("/?mail-toolbar&language=en&theme=light");
|
||||
await expect(mailboxRows(page).first()).toContainText("Alpha message 1");
|
||||
state.profiles = [profile("Beta")];
|
||||
await reload(page).click();
|
||||
await expect(mailboxRows(page).first()).toContainText("Beta message 1");
|
||||
await expect(page.getByRole("combobox", { name: "Mailbox profile" })).toHaveValue("Beta");
|
||||
await expect(page.getByRole("option", { name: "Alpha mailbox" })).toHaveCount(0);
|
||||
state.profiles = [];
|
||||
await reload(page).click();
|
||||
await expect(reload(page)).toBeEnabled();
|
||||
await expect(page.getByRole("combobox", { name: "Mailbox profile" })).toBeDisabled();
|
||||
await expect(mailboxRows(page)).toHaveCount(0);
|
||||
expect(state.errors).toEqual([]);
|
||||
expect(state.writes).toEqual([]);
|
||||
});
|
||||
|
||||
test("Mail JMAP Reload restarts its cursor chain but retains the server-side search", async ({ page }) => {
|
||||
const state = await mockMailbox(page);
|
||||
state.profiles = [profile("Alpha", "jmap")];
|
||||
await page.goto("/?mail-toolbar&language=en&theme=light");
|
||||
await expect(mailboxRows(page)).toHaveCount(10);
|
||||
await page.getByPlaceholder("Search messages").fill("message");
|
||||
await expect.poll(() => state.reads.some(url => url.searchParams.get("q") === "message")).toBe(true);
|
||||
await expect(reload(page)).toBeEnabled();
|
||||
await page.getByRole("button", { name: "Next page", exact: true }).click();
|
||||
await expect(mailboxRows(page).first()).toContainText("Alpha message 11");
|
||||
const before = state.reads.length;
|
||||
await reload(page).click();
|
||||
await expect(reload(page)).toBeEnabled();
|
||||
await expect(page.getByPlaceholder("Search messages")).toHaveValue("message");
|
||||
await expect(page.locator(".data-grid-page-controls")).toContainText("Page 1 of 3");
|
||||
await expect(mailboxRows(page).first()).toContainText("Alpha message 1");
|
||||
const reads = mainMailboxReads(state.reads.slice(before));
|
||||
expect(reads.map(url => url.pathname)).toEqual(["/api/v1/mail/profiles", "/api/v1/mail/profiles/Alpha/mailbox/bootstrap", "/api/v1/mail/profiles/Alpha/mailbox/messages"]);
|
||||
expect(reads[2].searchParams.get("protocol")).toBe("jmap");
|
||||
expect(reads[2].searchParams.get("q")).toBe("message");
|
||||
expect(reads[2].searchParams.has("cursor")).toBe(false);
|
||||
expect(reads[2].searchParams.get("offset")).toBe("0");
|
||||
expect(state.errors).toEqual([]);
|
||||
expect(state.writes).toEqual([]);
|
||||
});
|
||||
|
||||
test("Mail profile switches discard late bootstrap and preview responses instead of showing a previous account", async ({ page }) => {
|
||||
const state = await mockMailbox(page);
|
||||
const heldBootstrap = deferred();
|
||||
let started = false;
|
||||
state.intercept = async (url, route) => {
|
||||
if (url.pathname === "/api/v1/mail/profiles/Alpha/mailbox/bootstrap") {
|
||||
started = true;
|
||||
await heldBootstrap.promise;
|
||||
await route.fulfill({ json: bootstrap(url) });
|
||||
state.releasedReads += 1;
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
};
|
||||
await page.goto("/?mail-toolbar&language=en&theme=light");
|
||||
await expect.poll(() => started).toBe(true);
|
||||
await page.getByRole("combobox", { name: "Mailbox profile" }).selectOption("Beta");
|
||||
await expect(mailboxRows(page).first()).toContainText("Beta message 1");
|
||||
heldBootstrap.release();
|
||||
await expect.poll(() => state.releasedReads).toBeGreaterThan(0);
|
||||
await expect(mailboxRows(page).first()).toContainText("Beta message 1");
|
||||
const heldPreview = deferred();
|
||||
let previewStarted = false;
|
||||
state.intercept = async (url, route) => {
|
||||
if (url.pathname === "/api/v1/mail/profiles/Beta/mailbox/messages/1") {
|
||||
previewStarted = true;
|
||||
await heldPreview.promise;
|
||||
await route.fulfill({ json: messageDetail(url) });
|
||||
state.releasedReads += 1;
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
};
|
||||
await mailboxRows(page).first().click();
|
||||
await expect.poll(() => previewStarted).toBe(true);
|
||||
await page.getByRole("combobox", { name: "Mailbox profile" }).selectOption("Alpha");
|
||||
await expect(mailboxRows(page).first()).toContainText("Alpha message 1");
|
||||
await mailboxRows(page).first().click();
|
||||
await expect(page.getByText("Fixture body Alpha 1", { exact: true })).toBeVisible();
|
||||
const released = state.releasedReads;
|
||||
heldPreview.release();
|
||||
await expect.poll(() => state.releasedReads).toBeGreaterThan(released);
|
||||
await expect(page.getByText("Fixture body Alpha 1", { exact: true })).toBeVisible();
|
||||
await expect(page.getByText("Fixture body Beta 1", { exact: true })).toHaveCount(0);
|
||||
expect(state.errors).toEqual([]);
|
||||
expect(state.writes).toEqual([]);
|
||||
});
|
||||
|
||||
test("Mail tools keeps advanced reads scoped, bounce permission explained, and Escape leaves selection intact", async ({ page }) => {
|
||||
const state = await mockMailbox(page);
|
||||
await page.goto("/?mail-toolbar&language=en&theme=light");
|
||||
await expect(mailboxRows(page)).toHaveCount(10);
|
||||
await mailboxRows(page).first().click();
|
||||
await expect(page.getByText("Fixture body Alpha 1", { exact: true })).toBeVisible();
|
||||
const tools = page.getByRole("button", { name: "Mailbox tools", exact: true });
|
||||
await tools.click();
|
||||
const dialog = page.getByRole("dialog", { name: "Mailbox tools" });
|
||||
await expect(dialog).toBeVisible();
|
||||
await expect(dialog.getByRole("button", { name: "Bounce status", exact: true })).toBeDisabled();
|
||||
for (let index = 0; index < 10; index += 1) {
|
||||
await page.keyboard.press("Tab");
|
||||
expect(await dialog.evaluate(element => element.contains(document.activeElement))).toBe(true);
|
||||
}
|
||||
await page.keyboard.press("Escape");
|
||||
await expect(dialog).not.toBeVisible();
|
||||
await expect(tools).toBeFocused();
|
||||
await expect(page.getByText("Fixture body Alpha 1", { exact: true })).toBeVisible();
|
||||
for (const [action, expectedPath] of [
|
||||
["Refresh available profiles", "/api/v1/mail/profiles"],
|
||||
["Refresh folders only", "/api/v1/mail/profiles/Alpha/mailbox/folders"],
|
||||
["Refresh messages only", "/api/v1/mail/profiles/Alpha/mailbox/messages"]
|
||||
]) {
|
||||
await tools.click();
|
||||
const before = state.reads.length;
|
||||
await dialog.getByRole("button", { name: action, exact: true }).click();
|
||||
await expect(dialog).not.toBeVisible();
|
||||
await expect(reload(page)).toBeEnabled();
|
||||
expect(mainMailboxReads(state.reads.slice(before)).map(url => url.pathname)).toEqual([expectedPath]);
|
||||
await expect(mailboxRows(page).first()).toHaveClass(/is-selected/);
|
||||
}
|
||||
expect(state.errors).toEqual([]);
|
||||
expect(state.writes).toEqual([]);
|
||||
});
|
||||
|
||||
test("Mail authority switch invalidates a previous tenant's pending profile catalogue", async ({ page }) => {
|
||||
const state = await mockMailbox(page);
|
||||
const held = deferred();
|
||||
let started = false;
|
||||
state.intercept = async (url, route) => {
|
||||
if (url.pathname === "/api/v1/mail/profiles" && !started) {
|
||||
started = true;
|
||||
await held.promise;
|
||||
await route.fulfill({ json: { profiles: [profile("PreviousTenant")] } });
|
||||
state.releasedReads += 1;
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
};
|
||||
state.profiles = [profile("CurrentTenant")];
|
||||
await page.goto("/?mail-toolbar&language=en&theme=light&switch-tenant");
|
||||
await expect.poll(() => started).toBe(true);
|
||||
await page.getByRole("button", { name: "Switch fixture tenant" }).click();
|
||||
await expect(mailboxRows(page).first()).toContainText("CurrentTenant message 1");
|
||||
held.release();
|
||||
await expect.poll(() => state.releasedReads).toBeGreaterThan(0);
|
||||
await expect(page.getByRole("combobox", { name: "Mailbox profile" })).toHaveValue("CurrentTenant");
|
||||
await expect(page.getByRole("option", { name: "PreviousTenant mailbox" })).toHaveCount(0);
|
||||
expect(state.errors).toEqual([]);
|
||||
expect(state.writes).toEqual([]);
|
||||
});
|
||||
|
||||
test("Mail narrow German workspace keeps its sole Reload visible and the grouped tools dialog within the viewport", async ({ page }) => {
|
||||
const state = await mockMailbox(page);
|
||||
await page.setViewportSize({ width: 390, height: 844 });
|
||||
await page.goto("/?mail-toolbar&language=de&theme=light&bounce-allowed");
|
||||
await expect(reload(page)).toBeEnabled();
|
||||
await expect(reload(page)).toHaveText(/Neu laden/);
|
||||
await expect(page.locator(".mailbox-message-head")).not.toBeVisible();
|
||||
expect(await mailboxRows(page).first().evaluate(element => getComputedStyle(element).gridTemplateColumns.split(" ").length)).toBe(1);
|
||||
expect((await page.locator(".mailbox-message-scroll").boundingBox())!.height).toBeGreaterThan(90);
|
||||
await expect(workspaceBar(page).locator('[data-page-action-slot="reload"]')).toHaveCount(1);
|
||||
const bounds = await reload(page).boundingBox();
|
||||
expect(bounds!.x).toBeGreaterThanOrEqual(0);
|
||||
expect(bounds!.x + bounds!.width).toBeLessThanOrEqual(390);
|
||||
expect(await page.evaluate(() => document.documentElement.scrollWidth <= window.innerWidth + 1)).toBe(true);
|
||||
await mailboxRows(page).first().click();
|
||||
const preview = page.getByText("Fixture body Alpha 1", { exact: true });
|
||||
await expect(preview).toBeVisible();
|
||||
await preview.scrollIntoViewIfNeeded();
|
||||
await expect(preview).toBeInViewport();
|
||||
await expect(reload(page)).toBeVisible();
|
||||
expect((await reload(page).boundingBox())!.y).toBe(bounds!.y);
|
||||
await page.getByRole("button", { name: "Postfachwerkzeuge", exact: true }).click();
|
||||
const dialog = page.getByRole("dialog", { name: "Postfachwerkzeuge" });
|
||||
await expect(dialog).toBeVisible();
|
||||
await expect(dialog.getByRole("button", { name: "Zustellrückläufer", exact: true })).toBeEnabled();
|
||||
expect(await dialog.evaluate(element => element.scrollWidth <= element.clientWidth + 1)).toBe(true);
|
||||
await page.keyboard.press("Escape");
|
||||
await expect(dialog).not.toBeVisible();
|
||||
await expect(page.getByText("Fixture body Alpha 1", { exact: true })).toBeVisible();
|
||||
expect(state.errors).toEqual([]);
|
||||
expect(state.writes).toEqual([]);
|
||||
});
|
||||
@@ -0,0 +1,296 @@
|
||||
import { expect, test, type Page } from "@playwright/test";
|
||||
|
||||
const sourceFile = {
|
||||
id: "fixture-archive", tenant_id: "fixture-tenant", owner_type: "user", owner_id: "fixture-user",
|
||||
display_path: "small-archive.zip", filename: "small-archive.zip", size_bytes: 280,
|
||||
content_type: "application/zip", checksum_sha256: "a".repeat(64), version_id: "fixture-version-7",
|
||||
created_at: "2026-01-01T12:00:00Z", updated_at: "2026-01-01T12:00:00Z", audit_relevant: false,
|
||||
shares: [], metadata: {}, deleted_at: null
|
||||
};
|
||||
|
||||
type RecordedCall = { path: string; body: Record<string, unknown>; contentType: string };
|
||||
|
||||
async function installFileFixtures(page: Page, options: { encrypted?: boolean; failPreviewOnce?: boolean; failConfirmation?: boolean; holdPreview?: Promise<void>; holdConfirmation?: Promise<void>; progress?: { current: Record<string, unknown> | null }; staged?: boolean } = {}) {
|
||||
const calls: RecordedCall[] = [];
|
||||
const forbiddenRequests: string[] = [];
|
||||
const importedFiles: Record<string, unknown>[] = [];
|
||||
const releasedStages: string[] = [];
|
||||
let previewFailed = false;
|
||||
// A broad **/api/** glob also matches Vite's /@fs/.../src/api/*.ts.
|
||||
// Restrict interception to actual API URLs so real module code is rendered.
|
||||
await page.route((url) => url.pathname.startsWith("/api/"), async (route) => {
|
||||
const request = route.request();
|
||||
const url = new URL(request.url());
|
||||
const path = url.pathname;
|
||||
if (request.method() === "DELETE" && path.startsWith("/api/v1/files/archive-staging/")) {
|
||||
releasedStages.push(path.split("/").slice(-1)[0]);
|
||||
await route.fulfill({ status: 204 });
|
||||
return;
|
||||
}
|
||||
if (request.method() === "GET") {
|
||||
if (path.startsWith("/api/v1/files/archive-progress/")) {
|
||||
const operationId = path.split("/").slice(-1)[0];
|
||||
const confirmation = calls.find((call) => call.path.endsWith("/archive-confirm") && call.body.operation_id === operationId);
|
||||
if (confirmation && options.progress?.current) await route.fulfill({ json: options.progress.current });
|
||||
else await route.fulfill({ status: 404, json: { detail: "No measured progress available" } });
|
||||
return;
|
||||
}
|
||||
if (path === "/api/v1/files/spaces") {
|
||||
await route.fulfill({ json: { spaces: [
|
||||
{ id: "user:fixture-user", label: "My files", owner_type: "user", owner_id: "fixture-user", space_type: "managed" },
|
||||
{ id: "group:fixture-team", label: "Team files", owner_type: "group", owner_id: "fixture-team", space_type: "managed" }
|
||||
] } });
|
||||
return;
|
||||
}
|
||||
const team = url.searchParams.get("owner_id") === "fixture-team";
|
||||
if (path === "/api/v1/files/folders") {
|
||||
await route.fulfill({ json: { folders: [{ id: team ? "team-output" : "personal-output", path: "extracted",
|
||||
owner_type: team ? "group" : "user", owner_id: team ? "fixture-team" : "fixture-user",
|
||||
name: "extracted", created_at: sourceFile.created_at, updated_at: sourceFile.updated_at }], total: 1, next_cursor: null } });
|
||||
return;
|
||||
}
|
||||
if (path === "/api/v1/files") {
|
||||
const files = team ? importedFiles : [sourceFile];
|
||||
await route.fulfill({ json: { files, total: files.length, next_cursor: null } });
|
||||
return;
|
||||
}
|
||||
}
|
||||
if (request.method() === "POST" && (path.endsWith("/fixture-archive/archive-preview") || path.endsWith("/fixture-archive/archive-confirm") || path === "/api/v1/files/archive-preview" || path === "/api/v1/files/archive-confirm")) {
|
||||
const contentType = request.headers()["content-type"] ?? "";
|
||||
let body: Record<string, unknown>;
|
||||
if (contentType.startsWith("application/json")) body = request.postDataJSON() as Record<string, unknown>;
|
||||
else {
|
||||
const form = await new Request(request.url(), { method: "POST", headers: { "content-type": contentType }, body: new Uint8Array(request.postDataBuffer()!) }).formData();
|
||||
body = Object.fromEntries([...form.entries()].map(([key, value]) => [key, typeof value === "string" ? value : value.name]));
|
||||
if (typeof body.selected_paths_json === "string") body.selected_paths = JSON.parse(body.selected_paths_json);
|
||||
}
|
||||
calls.push({ path, body, contentType: request.headers()["content-type"] ?? "" });
|
||||
if (path.endsWith("/archive-preview")) {
|
||||
if (options.holdPreview) await options.holdPreview;
|
||||
if (options.failPreviewOnce && !previewFailed) {
|
||||
previewFailed = true;
|
||||
await route.fulfill({ status: 400, json: { detail: "Managed archive version changed; reload and preview again" } });
|
||||
return;
|
||||
}
|
||||
await route.fulfill({ json: {
|
||||
preview_token: `fixture-preview-${calls.length}`, archive_format: "zip", file_count: 2, directory_count: 1,
|
||||
compressed_size_bytes: 280, expanded_size_bytes: 6, expires_at: "2099-01-01T12:00:00Z",
|
||||
requires_password: Boolean(options.encrypted), password_verified: Boolean(body.password),
|
||||
...(options.staged ? { staged_upload_id: "fixture-staged-archive" } : {}),
|
||||
entries: [
|
||||
{ path: "folder", kind: "directory", size_bytes: 0, encrypted: false },
|
||||
{ path: "folder/one.txt", kind: "file", size_bytes: 3, compressed_size_bytes: 5, encrypted: Boolean(options.encrypted) },
|
||||
{ path: "two.txt", kind: "file", size_bytes: 3, compressed_size_bytes: 5, encrypted: Boolean(options.encrypted) }
|
||||
]
|
||||
} });
|
||||
return;
|
||||
}
|
||||
if (options.holdConfirmation) await options.holdConfirmation;
|
||||
if (options.failConfirmation) {
|
||||
await route.fulfill({ status: 400, json: { detail: "Target file already exists: extracted/folder/one.txt" } });
|
||||
return;
|
||||
}
|
||||
for (const [index, selected] of (body.selected_paths as string[]).entries()) {
|
||||
importedFiles.push({ ...sourceFile, id: `imported-${index}`, owner_type: body.owner_type, owner_id: body.owner_id,
|
||||
display_path: `${body.path}/${selected}`, filename: selected.split("/").slice(-1)[0], version_id: `imported-version-${index}` });
|
||||
}
|
||||
await route.fulfill({ json: { files: importedFiles } });
|
||||
return;
|
||||
}
|
||||
forbiddenRequests.push(`${request.method()} ${path}`);
|
||||
await route.fulfill({ status: 403, json: { detail: "Unexpected fixture request; no real API access permitted" } });
|
||||
});
|
||||
return { calls, forbiddenRequests, importedFiles, releasedStages };
|
||||
}
|
||||
|
||||
async function openExistingArchive(page: Page, language = "en", contextMenu = false) {
|
||||
const name = language === "de" ? "Archiv entpacken" : "Unpack archive";
|
||||
await page.goto(`/?managed-archive&language=${language}`);
|
||||
const row = page.locator(".file-row").filter({ hasText: "small-archive.zip" });
|
||||
await expect(row).toBeVisible();
|
||||
await expect(page.getByRole("button", { name, exact: true })).toHaveCount(0);
|
||||
await row.click({ button: contextMenu ? "right" : "left" });
|
||||
if (contextMenu) await page.getByRole("menuitem", { name, exact: true }).click();
|
||||
else await page.getByRole("button", { name, exact: true }).click();
|
||||
const dialog = page.getByRole("dialog", { name, exact: true });
|
||||
await expect(dialog).toBeVisible();
|
||||
await expect(dialog).toContainText("small-archive.zip");
|
||||
await expect(dialog.locator('input[type="file"]')).toHaveCount(0);
|
||||
await dialog.locator("select").selectOption("group:fixture-team");
|
||||
await dialog.getByRole("button", { name: "extracted", exact: true }).click();
|
||||
return dialog;
|
||||
}
|
||||
|
||||
for (const language of ["en", "de"]) {
|
||||
test(`real Files page unpacks a selected managed archive without re-upload in ${language}`, async ({ page }) => {
|
||||
let finish!: () => void;
|
||||
const holdConfirmation = new Promise<void>((resolve) => { finish = resolve; });
|
||||
const fixture = await installFileFixtures(page, { holdConfirmation });
|
||||
const dialog = await openExistingArchive(page, language);
|
||||
await dialog.getByRole("button", { name: language === "de" ? "Archivvorschau" : "Preview archive", exact: true }).click();
|
||||
await expect(dialog.locator(".archive-entry-row")).toHaveCount(3);
|
||||
await dialog.locator(".archive-entry-row").filter({ hasText: "two.txt" }).locator('input[type="checkbox"]').uncheck();
|
||||
await dialog.getByRole("button", { name: "Import selected", exact: true }).click();
|
||||
await expect.poll(() => fixture.calls.filter((call) => call.path.endsWith("/archive-confirm")).length).toBe(1);
|
||||
await expect(dialog.getByRole("button", { name: "Import selected", exact: true, includeHidden: true })).toBeDisabled();
|
||||
await expect(dialog.locator(".loading-frame-overlay")).toBeVisible();
|
||||
await expect(dialog.locator(".loading-frame-overlay")).toHaveCSS("backdrop-filter", /blur/);
|
||||
await expect(dialog.locator(".loading-envelope")).toHaveCount(0);
|
||||
await expect(dialog.locator("[inert]")).toHaveCount(1);
|
||||
await expect(dialog.locator(".dialog-close")).toBeDisabled();
|
||||
await expect(dialog.getByRole("progressbar")).not.toHaveAttribute("value");
|
||||
await page.keyboard.press("Escape");
|
||||
await expect(dialog).toBeVisible();
|
||||
finish();
|
||||
await expect(dialog).toHaveCount(0);
|
||||
await expect(page.locator(".file-row").filter({ hasText: "small-archive.zip" })).toBeVisible();
|
||||
expect(fixture.calls).toHaveLength(2);
|
||||
expect(fixture.calls.every((call) => call.contentType.startsWith("application/json"))).toBe(true);
|
||||
expect(fixture.calls[0].body).toEqual({ source_version_id: "fixture-version-7", owner_type: "group", owner_id: "fixture-team", path: "extracted" });
|
||||
expect(fixture.calls[1].body).toEqual({ ...fixture.calls[0].body, preview_token: "fixture-preview-1", selected_paths: ["folder/one.txt"], operation_id: expect.any(String) });
|
||||
expect(fixture.importedFiles.map((file) => file.display_path)).toEqual(["extracted/folder/one.txt"]);
|
||||
expect(fixture.forbiddenRequests).toEqual([]);
|
||||
});
|
||||
}
|
||||
|
||||
test("managed context-menu flow verifies a ZIP password in the shared dialog", async ({ page }) => {
|
||||
const fixture = await installFileFixtures(page, { encrypted: true });
|
||||
const dialog = await openExistingArchive(page, "en", true);
|
||||
await dialog.getByRole("button", { name: "Preview archive", exact: true }).click();
|
||||
await expect(dialog.getByRole("button", { name: "Import selected", exact: true })).toBeDisabled();
|
||||
await dialog.locator('input[type="password"]').fill("fixture-only-password");
|
||||
await dialog.getByRole("button", { name: "Verify password", exact: false }).click();
|
||||
await expect.poll(() => fixture.calls.length).toBe(2);
|
||||
await expect(dialog.getByRole("button", { name: "Import selected", exact: true })).toBeEnabled();
|
||||
await dialog.getByRole("button", { name: "Import selected", exact: true }).click();
|
||||
await expect(dialog).toHaveCount(0);
|
||||
expect(fixture.calls[1].body.password).toBe("fixture-only-password");
|
||||
expect(fixture.calls[2].body.password).toBe("fixture-only-password");
|
||||
expect(fixture.calls[2].body.preview_token).toBe("fixture-preview-2");
|
||||
expect(fixture.calls.every((call) => call.body.source_version_id === "fixture-version-7")).toBe(true);
|
||||
expect(fixture.forbiddenRequests).toEqual([]);
|
||||
});
|
||||
|
||||
test("cancelling an uploaded archive releases only its temporary stage without importing", async ({ page }) => {
|
||||
const fixture = await installFileFixtures(page, { staged: true });
|
||||
await page.goto("/?managed-archive&language=en");
|
||||
await expect(page.locator(".file-row").filter({ hasText: "small-archive.zip" })).toBeVisible();
|
||||
await page.getByRole("button", { name: "Upload", exact: true }).click();
|
||||
const dialog = page.getByRole("dialog");
|
||||
await dialog.getByRole("checkbox", { name: "Preview and unpack archive", exact: true }).focus();
|
||||
await page.keyboard.press("Space");
|
||||
await dialog.locator('input[type="file"]').setInputFiles({ name: "cancelled.zip", mimeType: "application/zip", buffer: Buffer.from("fixture archive") });
|
||||
await expect(dialog.locator(".archive-entry-row")).toHaveCount(3);
|
||||
await dialog.getByRole("button", { name: "Cancel", exact: true }).click();
|
||||
await expect(dialog).toHaveCount(0);
|
||||
await expect.poll(() => fixture.releasedStages).toEqual(["fixture-staged-archive"]);
|
||||
expect(fixture.calls.filter((call) => call.path.endsWith("/archive-confirm"))).toHaveLength(0);
|
||||
expect(fixture.importedFiles).toEqual([]);
|
||||
expect(fixture.forbiddenRequests).toEqual([]);
|
||||
});
|
||||
|
||||
test("managed preview and confirmation failures stay visible inside the real dialog", async ({ page }) => {
|
||||
const fixture = await installFileFixtures(page, { failPreviewOnce: true, failConfirmation: true });
|
||||
const dialog = await openExistingArchive(page);
|
||||
await dialog.getByRole("button", { name: "Preview archive", exact: true }).click();
|
||||
await expect(dialog.getByText(/Managed archive version changed/)).toBeVisible();
|
||||
await expect(dialog).toContainText("small-archive.zip");
|
||||
await dialog.getByRole("button", { name: "Preview archive", exact: true }).click();
|
||||
await expect(dialog.locator(".archive-entry-row")).toHaveCount(3);
|
||||
await dialog.getByRole("button", { name: "Import selected", exact: true }).click();
|
||||
await expect(dialog.getByText(/Target file already exists/)).toBeVisible();
|
||||
await expect(dialog.locator(".loading-frame-overlay")).toHaveCount(0);
|
||||
await expect(dialog.locator("[inert]")).toHaveCount(0);
|
||||
await expect(dialog.locator(".dialog-close")).toBeEnabled();
|
||||
await expect(dialog.getByRole("button", { name: "Import selected", exact: true })).toBeEnabled();
|
||||
await dialog.getByRole("button", { name: "Change destination", exact: true }).click();
|
||||
await expect(dialog.getByRole("button", { name: "Preview archive", exact: true })).toBeVisible();
|
||||
await expect(dialog.locator(".archive-entry-row")).toHaveCount(0);
|
||||
expect(fixture.importedFiles).toEqual([]);
|
||||
expect(fixture.forbiddenRequests).toEqual([]);
|
||||
});
|
||||
|
||||
test("managed extraction is unavailable without the download permission", async ({ page }) => {
|
||||
const fixture = await installFileFixtures(page);
|
||||
await page.goto("/?managed-archive&language=en&no-download");
|
||||
await page.locator(".file-row").filter({ hasText: "small-archive.zip" }).click();
|
||||
await expect(page.getByRole("button", { name: "Unpack archive", exact: true })).toBeDisabled();
|
||||
expect(fixture.calls).toEqual([]);
|
||||
expect(fixture.forbiddenRequests).toEqual([]);
|
||||
});
|
||||
|
||||
test("managed preview covers and blurs existing dialog controls without fabricating progress", async ({ page }) => {
|
||||
let release!: () => void;
|
||||
const holdPreview = new Promise<void>((resolve) => { release = resolve; });
|
||||
const fixture = await installFileFixtures(page, { holdPreview });
|
||||
const dialog = await openExistingArchive(page);
|
||||
await dialog.getByRole("button", { name: "Preview archive", exact: true }).click();
|
||||
const overlay = dialog.locator(".loading-frame-overlay");
|
||||
await expect(overlay).toBeVisible();
|
||||
await expect(overlay).toContainText("Inspecting the archive");
|
||||
await expect(dialog.getByRole("progressbar")).not.toHaveAttribute("value");
|
||||
await expect(dialog.locator(".loading-envelope")).toHaveCount(0);
|
||||
await expect(dialog.locator(".dialog-close")).toBeDisabled();
|
||||
await page.keyboard.press("Escape");
|
||||
await expect(dialog).toBeVisible();
|
||||
release();
|
||||
await expect(overlay).toHaveCount(0);
|
||||
await expect(dialog.locator(".archive-entry-row")).toHaveCount(3);
|
||||
expect(fixture.forbiddenRequests).toEqual([]);
|
||||
});
|
||||
|
||||
test("actual server counters update the blurred overlay and finalization never claims completion", async ({ page }) => {
|
||||
let release!: () => void;
|
||||
const holdConfirmation = new Promise<void>((resolve) => { release = resolve; });
|
||||
const progress = { current: { phase: "extracting", completed_files: 1, total_files: 2, completed_bytes: 3, total_bytes: 6, status: "running" } as Record<string, unknown> };
|
||||
const fixture = await installFileFixtures(page, { holdConfirmation, progress });
|
||||
const dialog = await openExistingArchive(page);
|
||||
await dialog.getByRole("button", { name: "Preview archive", exact: true }).click();
|
||||
await expect(dialog.locator(".archive-entry-row")).toHaveCount(3);
|
||||
await dialog.getByRole("button", { name: "Import selected", exact: true }).click();
|
||||
await expect(dialog.getByRole("progressbar")).toHaveAttribute("value", "50");
|
||||
await expect(dialog.locator(".loading-frame-overlay")).toContainText("1 of 2 files");
|
||||
await expect(dialog.locator(".loading-frame-overlay")).toContainText("3 B of 6 B");
|
||||
progress.current = { ...progress.current, phase: "finalizing", completed_files: 2, completed_bytes: 6 };
|
||||
await expect(dialog.locator(".loading-frame-overlay")).toContainText("Finalizing and committing changes");
|
||||
await expect(dialog.getByRole("progressbar")).not.toHaveAttribute("value");
|
||||
await expect(dialog).toBeVisible();
|
||||
release();
|
||||
await expect(dialog).toHaveCount(0);
|
||||
expect(fixture.calls.filter((call) => call.path.endsWith("/archive-confirm"))).toHaveLength(1);
|
||||
expect(fixture.forbiddenRequests).toEqual([]);
|
||||
});
|
||||
|
||||
test("new archive upload preview and confirmation use the same no-envelope progress overlay", async ({ page }) => {
|
||||
let previewReady!: () => void;
|
||||
let importReady!: () => void;
|
||||
const holdPreview = new Promise<void>((resolve) => { previewReady = resolve; });
|
||||
const holdConfirmation = new Promise<void>((resolve) => { importReady = resolve; });
|
||||
const progress = { current: { phase: "storing", completed_files: 1, total_files: 2, completed_bytes: 3, total_bytes: 6, status: "running" } as Record<string, unknown> };
|
||||
const fixture = await installFileFixtures(page, { holdPreview, holdConfirmation, progress, staged: true });
|
||||
await page.goto("/?managed-archive&language=en");
|
||||
await expect(page.locator(".file-row").filter({ hasText: "small-archive.zip" })).toBeVisible();
|
||||
await page.getByRole("button", { name: "Upload", exact: true }).click();
|
||||
const dialog = page.getByRole("dialog");
|
||||
await dialog.getByRole("checkbox", { name: "Preview and unpack archive", exact: true }).focus();
|
||||
await page.keyboard.press("Space");
|
||||
await dialog.locator('input[type="file"]').setInputFiles({ name: "new-archive.zip", mimeType: "application/zip", buffer: Buffer.from("fixture ZIP bytes; backend is intercepted") });
|
||||
await expect(dialog.locator(".loading-frame-overlay")).toBeVisible();
|
||||
await expect(dialog.locator(".loading-envelope")).toHaveCount(0);
|
||||
await expect(dialog.locator(".dialog-close")).toBeDisabled();
|
||||
await expect(dialog.getByRole("progressbar")).not.toHaveAttribute("value", "100");
|
||||
previewReady();
|
||||
await expect(dialog.locator(".archive-entry-row")).toHaveCount(3);
|
||||
await expect(dialog.locator(".loading-frame-overlay")).toHaveCount(0);
|
||||
await dialog.getByRole("button", { name: "Import selected", exact: true }).click();
|
||||
await expect(dialog.locator(".loading-frame-overlay")).toContainText("Storing extracted files");
|
||||
await expect(dialog.getByRole("progressbar")).toHaveAttribute("value", "50");
|
||||
await expect(dialog.locator(".loading-envelope")).toHaveCount(0);
|
||||
importReady();
|
||||
await expect(dialog).toHaveCount(0);
|
||||
expect(fixture.calls.filter((call) => call.path === "/api/v1/files/archive-confirm")).toHaveLength(1);
|
||||
const confirmation = fixture.calls.find((call) => call.path === "/api/v1/files/archive-confirm")!;
|
||||
expect(confirmation.body.staged_upload_id).toBe("fixture-staged-archive");
|
||||
expect(confirmation.body.file).toBeUndefined();
|
||||
expect(fixture.forbiddenRequests).toEqual([]);
|
||||
});
|
||||
@@ -0,0 +1,116 @@
|
||||
import { expect, test, type Page } from "@playwright/test";
|
||||
|
||||
async function mockModules(page: Page) {
|
||||
const errors: string[] = [];
|
||||
const writes: string[] = [];
|
||||
page.on("pageerror", error => errors.push(error.message));
|
||||
await page.route(url => url.pathname.startsWith("/api/"), async route => {
|
||||
const path = new URL(route.request().url()).pathname;
|
||||
if (route.request().method() !== "GET") writes.push(path);
|
||||
let body: unknown = {};
|
||||
if (path.startsWith("/api/v1/committee/workspace/")) body = { records: [], total: 0, offset: 0, limit: 200 };
|
||||
else if (path === "/api/v1/voting") body = { ballots: [] };
|
||||
else if (path === "/api/v1/scheduling/requests") body = { requests: [] };
|
||||
else if (path.endsWith("/source-snapshots")) body = { available: true, snapshots: [] };
|
||||
else if (path.endsWith("/list-snapshots")) body = { snapshots: [] };
|
||||
else if (path.endsWith("/review-queue")) body = { candidates: [] };
|
||||
else if (path.endsWith("/assurance/nodes")) body = { nodes: [] };
|
||||
else if (path.endsWith("/assurance/summary")) body = { node_count: 0, edge_count: 0, by_kind: {}, by_state: {} };
|
||||
else if (path === "/api/v1/organizations/model") body = { unit_types: [{ id: "unit-type", name: "Department", slug: "department", is_active: true }], structures: [], relation_types: [], units: [], relations: [], function_types: [], functions: [] };
|
||||
else if (path === "/api/v1/idm/typed-groups") body = { groups: [{ id: "group", tenant_id: "layout-tenant", key: "reviewers", name: "Reviewers", group_type: "team", status: "active", source_provider: "manual", properties: {}, provenance: {}, revision: 1 }] };
|
||||
else if (path === "/api/v1/idm/relationships") body = { relationships: [] };
|
||||
else if (path === "/api/v1/idm/organization-identities") body = { identities: [] };
|
||||
return route.fulfill({ json: body });
|
||||
});
|
||||
return { errors, writes };
|
||||
}
|
||||
|
||||
for (const module of ["committee", "voting", "scheduling"] as const) {
|
||||
test(`${module} keeps Reload immediately before New at the upper right`, async ({ page }) => {
|
||||
const fixture = await mockModules(page);
|
||||
await page.setViewportSize({ width: 1280, height: 900 });
|
||||
await page.goto(`/?module-layouts=${module}&language=en&theme=light`);
|
||||
const toolbar = page.locator('[data-workspace-action-scope="workspace"]').first();
|
||||
const create = toolbar.locator('[data-page-action-slot="create"] button');
|
||||
const reload = toolbar.locator('[data-page-action-slot="reload"] button');
|
||||
await expect(create).toBeVisible();
|
||||
await expect(reload).toBeEnabled();
|
||||
const [barBox, createBox, reloadBox] = await Promise.all([toolbar.boundingBox(), create.boundingBox(), reload.boundingBox()]);
|
||||
expect(createBox!.x).toBeGreaterThan(barBox!.x + barBox!.width / 2);
|
||||
expect(Math.abs(barBox!.x + barBox!.width - createBox!.x - createBox!.width)).toBeLessThanOrEqual(20);
|
||||
expect(Math.abs(createBox!.y - reloadBox!.y)).toBeLessThan(3);
|
||||
expect(reloadBox!.x + reloadBox!.width).toBeLessThanOrEqual(createBox!.x + 1);
|
||||
expect(createBox!.x - reloadBox!.x - reloadBox!.width).toBeLessThan(20);
|
||||
expect(barBox!.y).toBeLessThan(20);
|
||||
expect(fixture.errors).toEqual([]);
|
||||
expect(fixture.writes).toEqual([]);
|
||||
});
|
||||
}
|
||||
|
||||
test("Scheduling retains workspace actions when its editor opens and in read-only mode", async ({ page }) => {
|
||||
const fixture = await mockModules(page);
|
||||
await page.goto("/?module-layouts=scheduling&language=en&theme=light");
|
||||
const toolbar = page.locator('[data-workspace-action-scope="workspace"]');
|
||||
const create = toolbar.locator('[data-page-action-slot="create"] button');
|
||||
await expect(create).toBeEnabled();
|
||||
await create.click();
|
||||
await expect(page.locator('[data-workspace-action-scope="editor-pane"]')).toBeVisible();
|
||||
await expect(create).toBeVisible();
|
||||
await expect(toolbar.locator('[data-page-action-slot="reload"] button')).toBeVisible();
|
||||
expect(fixture.errors).toEqual([]);
|
||||
expect(fixture.writes).toEqual([]);
|
||||
await page.goto("/?module-layouts=scheduling&language=en&read-only&theme=light");
|
||||
await expect(create).toBeVisible();
|
||||
await expect(create).toBeDisabled();
|
||||
});
|
||||
|
||||
for (const module of ["organizations", "idm"] as const) {
|
||||
test(`${module} table cards have no inset or negative overflow`, async ({ page }) => {
|
||||
const fixture = await mockModules(page);
|
||||
await page.setViewportSize({ width: 1280, height: 900 });
|
||||
await page.goto(`/?module-layouts=${module}&language=en&theme=light`);
|
||||
const cards = page.locator('.card:has([data-card-body-layout="table"])');
|
||||
await expect(cards.first()).toBeVisible();
|
||||
await expect(page.getByText(module === "idm" ? "Reviewers" : "Department", { exact: true })).toBeVisible();
|
||||
const geometry = await cards.evaluateAll(elements => elements.map(card => {
|
||||
const body = card.querySelector('.card-body')!;
|
||||
const grid = card.querySelector('.data-grid')!;
|
||||
const cardRect = card.getBoundingClientRect();
|
||||
const gridRect = grid.getBoundingClientRect();
|
||||
return { left: gridRect.left - cardRect.left, right: cardRect.right - gridRect.right, padding: getComputedStyle(body).paddingLeft };
|
||||
}));
|
||||
expect(geometry.length).toBeGreaterThanOrEqual(2);
|
||||
for (const item of geometry) {
|
||||
expect(Math.abs(item.left)).toBeLessThanOrEqual(2);
|
||||
expect(Math.abs(item.right)).toBeLessThanOrEqual(2);
|
||||
expect(item.padding).toBe("0px");
|
||||
}
|
||||
if (module === "idm") {
|
||||
const [first, second] = await Promise.all([cards.nth(0).boundingBox(), cards.nth(1).boundingBox()]);
|
||||
expect(second!.y - first!.y - first!.height).toBeGreaterThanOrEqual(12);
|
||||
await expect(page.getByText("Business membership is an institutional fact.", { exact: false })).toHaveCount(0);
|
||||
}
|
||||
expect(fixture.errors).toEqual([]);
|
||||
expect(fixture.writes).toEqual([]);
|
||||
});
|
||||
}
|
||||
|
||||
for (const width of [1280, 390]) {
|
||||
test(`Risk Compliance uses shared cards and responsive layout at ${width}px`, async ({ page }) => {
|
||||
const fixture = await mockModules(page);
|
||||
await page.setViewportSize({ width, height: 900 });
|
||||
await page.goto("/?module-layouts=risk&language=en&theme=light");
|
||||
await expect(page.getByRole("heading", { name: "Review queue" })).toBeVisible();
|
||||
await expect(page.getByRole("heading", { name: "Candidate evidence" })).toBeVisible();
|
||||
const toolbar = page.locator('[data-workspace-action-scope="workspace"]');
|
||||
await expect(toolbar.locator('[data-page-action-slot="reload"] button')).toBeEnabled();
|
||||
await page.getByRole("tab", { name: "Sources", exact: true }).click();
|
||||
await expect(page.getByRole("heading", { name: "Connector evidence" })).toBeVisible();
|
||||
await page.getByRole("tab", { name: "Assurance", exact: true }).click();
|
||||
await expect(page.getByRole("heading", { name: "Assurance objects" })).toBeVisible();
|
||||
await expect(page.locator('.risk-panel, .risk-toolbar')).toHaveCount(0);
|
||||
expect(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth + 1)).toBe(true);
|
||||
expect(fixture.errors).toEqual([]);
|
||||
expect(fixture.writes).toEqual([]);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
import { expect, test } from "@playwright/test";
|
||||
|
||||
test("nested list filter owns keyboard focus and Escape only closes the filter", async ({ page }) => {
|
||||
await page.goto("/?multi-select-filter");
|
||||
await page.getByRole("button", { name: "Open filter dialog", exact: true }).click();
|
||||
const owner = page.locator(".dialog-panel");
|
||||
const trigger = owner.getByRole("button", { name: "Fixture tags", exact: true });
|
||||
await trigger.click();
|
||||
const popup = page.locator(".multi-select-filter-popover");
|
||||
await expect(popup).toHaveAttribute("data-dialog-stack-state", "topmost");
|
||||
await expect(owner).toHaveAttribute("inert", "");
|
||||
await expect(popup.getByRole("button", { name: "Close filter", exact: true })).toBeFocused();
|
||||
await page.keyboard.press("Tab");
|
||||
await expect(popup.getByRole("button", { name: "Select all", exact: true })).toBeFocused();
|
||||
await page.keyboard.press("Tab");
|
||||
await expect(popup.getByRole("button", { name: "Deselect all", exact: true })).toBeFocused();
|
||||
await page.keyboard.press("Space");
|
||||
await expect(popup.getByRole("checkbox").first()).not.toBeChecked();
|
||||
await page.keyboard.press("Tab");
|
||||
await expect(popup.getByRole("checkbox").first()).toBeFocused();
|
||||
await page.keyboard.press("Space");
|
||||
await expect(popup.getByRole("checkbox").first()).toBeChecked();
|
||||
await popup.getByRole("checkbox").last().focus();
|
||||
await page.keyboard.press("Tab");
|
||||
await expect(popup.getByRole("button", { name: "Close filter", exact: true })).toBeFocused();
|
||||
await page.keyboard.press("Shift+Tab");
|
||||
await expect(popup.getByRole("checkbox").last()).toBeFocused();
|
||||
await page.keyboard.press("Escape");
|
||||
await expect(popup).toHaveCount(0);
|
||||
await expect(owner).toBeVisible();
|
||||
await expect(owner).not.toHaveAttribute("inert");
|
||||
await expect(trigger).toBeFocused();
|
||||
await page.keyboard.press("Escape");
|
||||
await expect(owner).toHaveCount(0);
|
||||
});
|
||||
|
||||
test("outside pointer dismisses only the nested filter, not its parent dialog", async ({ page }) => {
|
||||
await page.goto("/?multi-select-filter");
|
||||
await page.getByRole("button", { name: "Open filter dialog", exact: true }).click();
|
||||
const owner = page.locator(".dialog-panel");
|
||||
await owner.getByRole("button", { name: "Fixture tags", exact: true }).click();
|
||||
await expect(page.locator(".multi-select-filter-popover")).toBeVisible();
|
||||
await page.mouse.click(4, 4);
|
||||
await expect(page.locator(".multi-select-filter-popover")).toHaveCount(0);
|
||||
await expect(owner).toBeVisible();
|
||||
await expect(owner.getByRole("button", { name: "Fixture tags", exact: true })).toBeFocused();
|
||||
});
|
||||
|
||||
for (const modal of [false, true]) test(`80-character labels wrap without horizontal scrolling (${modal ? "modal" : "standalone"})`, async ({ page }) => {
|
||||
await page.setViewportSize({ width: 320, height: 600 });
|
||||
await page.goto("/?multi-select-filter");
|
||||
if (modal) await page.getByRole("button", { name: "Open filter dialog", exact: true }).click();
|
||||
const root = modal ? page.locator(".dialog-panel") : page.locator("main");
|
||||
await root.getByRole("button", { name: "Fixture tags", exact: true }).click();
|
||||
const popup = page.locator(".multi-select-filter-popover");
|
||||
await expect(popup).toBeVisible();
|
||||
const sizes = await popup.evaluate((element) => {
|
||||
const options = element.querySelector(".data-grid-list-filter-options")!;
|
||||
const rect = element.getBoundingClientRect();
|
||||
return { left: rect.left, right: rect.right, bottom: rect.bottom, height: window.innerHeight,
|
||||
viewport: window.innerWidth, popupOverflow: element.scrollWidth - element.clientWidth,
|
||||
listOverflow: options.scrollWidth - options.clientWidth, parent: element.parentElement?.tagName };
|
||||
});
|
||||
expect(sizes.parent).toBe("BODY");
|
||||
expect(sizes.left).toBeGreaterThanOrEqual(0);
|
||||
expect(sizes.right).toBeLessThanOrEqual(sizes.viewport);
|
||||
expect(sizes.bottom).toBeLessThanOrEqual(sizes.height);
|
||||
expect(sizes.popupOverflow).toBeLessThanOrEqual(1);
|
||||
expect(sizes.listOverflow).toBeLessThanOrEqual(1);
|
||||
});
|
||||
@@ -0,0 +1,86 @@
|
||||
import { expect, test } from "@playwright/test";
|
||||
|
||||
test("collapsed rail keeps visible group separators; opening settings is clean", async ({ page }) => {
|
||||
await page.goto("/?navigation-layout");
|
||||
const rail = page.locator(".icon-rail");
|
||||
await expect(rail).not.toHaveClass(/expanded/);
|
||||
await expect(rail.getByRole("separator")).toHaveCount(2);
|
||||
for (const separator of await rail.getByRole("separator").all()) {
|
||||
await expect(separator).toBeVisible();
|
||||
expect((await separator.boundingBox())!.width).toBeGreaterThan(20);
|
||||
}
|
||||
await expect(page.getByTestId("navigation-draft")).toHaveText("null");
|
||||
const spacing = await page.locator(".navigation-preference-list > li").first().evaluate((row) => {
|
||||
const style = getComputedStyle(row);
|
||||
return { padding: Number.parseFloat(style.paddingLeft), gap: Number.parseFloat(style.columnGap) };
|
||||
});
|
||||
expect(spacing.padding).toBeGreaterThanOrEqual(8);
|
||||
expect(spacing.gap).toBeGreaterThanOrEqual(8);
|
||||
await page.getByRole("button", { name: "Expand navigation", exact: true }).click();
|
||||
await expect(rail.getByText("Documents", { exact: true })).toBeVisible();
|
||||
await expect(rail.getByRole("separator")).toHaveCount(0);
|
||||
await page.getByRole("button", { name: "Collapse navigation", exact: true }).click();
|
||||
await expect(rail.getByRole("separator")).toHaveCount(2);
|
||||
await expect(rail.getByText("Documents", { exact: true })).toBeHidden();
|
||||
});
|
||||
|
||||
for (const scope of ["system", "tenant", "user", "view"]) {
|
||||
test(`same editor supports add/remove, keyboard reorder and inheritance at ${scope} scope`, async ({ page }) => {
|
||||
await page.goto(`/?navigation-layout&scope=${scope}`);
|
||||
const list = page.getByRole("list", { name: "Navigation layout" });
|
||||
const files = list.locator('[data-navigation-id="files"]');
|
||||
await files.getByRole("button", { name: "Remove Files", exact: true }).click();
|
||||
await expect(files).toHaveCount(0);
|
||||
await page.getByRole("button", { name: "Add module", exact: true }).click();
|
||||
await expect(list.locator("li").last()).toHaveAttribute("data-navigation-id", "files");
|
||||
const handle = files.getByRole("button", { name: "Reorder Files", exact: true });
|
||||
const before = await list.locator("li").evaluateAll((rows) => rows.map((row) => row.getAttribute("data-navigation-id")));
|
||||
await handle.press("Space");
|
||||
await handle.press("ArrowUp");
|
||||
await expect(list.locator("li").last()).not.toHaveAttribute("data-navigation-id", "files");
|
||||
await handle.press("Escape");
|
||||
expect(await list.locator("li").evaluateAll((rows) => rows.map((row) => row.getAttribute("data-navigation-id")))).toEqual(before);
|
||||
await handle.press("Space"); await handle.press("ArrowUp"); await handle.press("Enter");
|
||||
await page.getByRole("button", { name: "Add separator", exact: true }).click();
|
||||
const separator = list.locator("li").last();
|
||||
await expect(separator).toHaveAttribute("data-navigation-kind", "separator");
|
||||
await separator.getByRole("textbox").fill("Custom group");
|
||||
const saved = JSON.parse(await page.getByTestId("navigation-draft").textContent() ?? "null");
|
||||
expect(saved.separators.at(-1).label).toBe("Custom group");
|
||||
await separator.getByRole("button", { name: "Remove Custom group", exact: true }).click();
|
||||
if (scope !== "system") await expect(list.getByRole("button", { name: "Remove Dashboard", exact: true })).toBeDisabled();
|
||||
await page.getByRole("button", { name: "Use inherited layout", exact: true }).click();
|
||||
await expect(page.getByTestId("navigation-draft")).toHaveText("null");
|
||||
});
|
||||
}
|
||||
|
||||
test("native pointer drag reorders modules and separators", async ({ page }) => {
|
||||
await page.goto("/?navigation-layout");
|
||||
const list = page.getByRole("list", { name: "Navigation layout" });
|
||||
const files = list.locator('[data-navigation-id="files"]');
|
||||
await files.getByRole("button", { name: "Reorder Files", exact: true }).dragTo(list.locator("li").first(), { targetPosition: { x: 15, y: 3 } });
|
||||
await expect(list.locator("li").first()).toHaveAttribute("data-navigation-id", "files");
|
||||
const separator = list.locator('[data-navigation-kind="separator"]').first();
|
||||
const separatorId = await separator.getAttribute("data-navigation-id");
|
||||
await separator.getByRole("button", { name: /^Reorder / }).dragTo(list.locator("li").first(), { targetPosition: { x: 15, y: 3 } });
|
||||
await expect(list.locator("li").first()).toHaveAttribute("data-navigation-id", separatorId!);
|
||||
});
|
||||
|
||||
test("no-op reordering stays clean and optional module positions survive other edits", async ({ page }) => {
|
||||
await page.goto("/?navigation-layout");
|
||||
const handle = page.getByRole("button", { name: "Reorder Files", exact: true });
|
||||
await handle.press("Space"); await handle.press("Enter");
|
||||
await expect(page.getByTestId("navigation-draft")).toHaveText("null");
|
||||
await page.goto("/?navigation-layout&unavailable");
|
||||
await page.getByRole("button", { name: "Move Mail up", exact: true }).click();
|
||||
const draft = JSON.parse(await page.getByTestId("navigation-draft").textContent() ?? "null");
|
||||
expect(draft.order).toContain("optional.navigation.absent");
|
||||
});
|
||||
|
||||
test("German narrow editor does not overflow and read-only controls cannot mutate", async ({ page }) => {
|
||||
await page.setViewportSize({ width: 390, height: 900 });
|
||||
await page.goto("/?navigation-layout&language=de&disabled");
|
||||
await expect(page.getByRole("button", { name: "Trennlinie hinzufügen", exact: true })).toBeDisabled();
|
||||
expect(await page.evaluate(() => document.documentElement.scrollWidth <= window.innerWidth)).toBe(true);
|
||||
await expect(page.getByTestId("navigation-draft")).toHaveText("null");
|
||||
});
|
||||
@@ -0,0 +1,112 @@
|
||||
import { expect, test } from "@playwright/test";
|
||||
|
||||
for (const language of ["en", "de"]) test(`notification status uses a shared multi-select dropdown (${language})`, async ({ page }) => {
|
||||
const errors: string[] = [];
|
||||
const requests: string[][] = [];
|
||||
page.on("pageerror", (error) => errors.push(error.message));
|
||||
await page.route("**/api/v1/notifications?**", (route) => {
|
||||
const states = new URL(route.request().url()).searchParams.getAll("status");
|
||||
requests.push(states);
|
||||
const notifications = ["pending", "failed", "sent"].filter((state) => !states.length || states.includes(state)).map((state) => ({
|
||||
id: state, tenant_id: "filter-tenant", source_module: "test", source_resource_type: "fixture", event_kind: "test", channel: "inbox",
|
||||
recipient_id: "filter-user", subject: `Message ${state}`, body_text: "Filter-only fixture", status: state,
|
||||
created_at: "2026-01-01T00:00:00Z", updated_at: "2026-01-01T00:00:00Z", attempts: [], metadata: {}, payload: {}, priority: 0, attempt_count: 0,
|
||||
}));
|
||||
return route.fulfill({ json: { notifications } });
|
||||
});
|
||||
await page.goto(`/?notification-filter&language=${language}`);
|
||||
const list = page.locator(".notifications-selection-list");
|
||||
await expect(list.locator("button")).toHaveCount(3);
|
||||
const trigger = page.locator(".multi-select-filter-trigger");
|
||||
await trigger.click();
|
||||
const popup = page.locator(".multi-select-filter-popover");
|
||||
await expect(popup.getByRole("checkbox")).toHaveCount(9);
|
||||
await popup.locator(".data-grid-list-filter-actions button").nth(1).click();
|
||||
await expect(list).toHaveCount(0);
|
||||
const countAfterClear = requests.length;
|
||||
await popup.getByRole("checkbox").nth(0).check();
|
||||
await popup.getByRole("checkbox").nth(6).check();
|
||||
await expect(list.locator("button")).toHaveCount(2);
|
||||
expect(requests[requests.length - 1]).toEqual(["pending", "failed"]);
|
||||
expect(requests.length).toBeGreaterThan(countAfterClear);
|
||||
await popup.locator(".data-grid-list-filter-actions button").first().click();
|
||||
await expect(list.locator("button")).toHaveCount(3);
|
||||
await page.keyboard.press("Escape");
|
||||
await expect(popup).toHaveCount(0);
|
||||
await expect(trigger).toBeFocused();
|
||||
expect(errors).toEqual([]);
|
||||
});
|
||||
|
||||
test("standalone list filter fits a narrow viewport and closes outside", async ({ page }) => {
|
||||
await page.setViewportSize({ width: 390, height: 700 });
|
||||
await page.route("**/api/v1/notifications?**", (route) => route.fulfill({ json: { notifications: [] } }));
|
||||
await page.goto("/?notification-filter&language=en");
|
||||
await page.locator(".multi-select-filter-trigger").click();
|
||||
const popup = page.locator(".multi-select-filter-popover");
|
||||
const box = await popup.boundingBox();
|
||||
expect(box!.x).toBeGreaterThanOrEqual(0);
|
||||
expect(box!.x + box!.width).toBeLessThanOrEqual(390);
|
||||
await page.mouse.click(385, 680);
|
||||
await expect(popup).toHaveCount(0);
|
||||
});
|
||||
|
||||
test("clearing the filter cancels a slow read and cannot restore old rows", async ({ page }) => {
|
||||
let releaseRead!: () => void;
|
||||
const heldRead = new Promise<void>((resolve) => { releaseRead = resolve; });
|
||||
let readStarted = false;
|
||||
const failedReads: string[] = [];
|
||||
page.on("requestfailed", (request) => { if (request.url().includes("/api/v1/notifications?")) failedReads.push(request.url()); });
|
||||
await page.route("**/api/v1/notifications?**", async (route) => {
|
||||
readStarted = true;
|
||||
await heldRead;
|
||||
await route.fulfill({ json: { notifications: [{ id: "stale", status: "sent", subject: "Stale notice", attempts: [] }] } });
|
||||
});
|
||||
await page.goto("/?notification-filter&language=en");
|
||||
await expect.poll(() => readStarted).toBe(true);
|
||||
await page.locator(".multi-select-filter-trigger").click();
|
||||
await page.locator(".multi-select-filter-popover .data-grid-list-filter-actions button").nth(1).click();
|
||||
await expect(page.locator(".notifications-note")).toHaveText("No notifications in this view.");
|
||||
releaseRead();
|
||||
await expect.poll(() => failedReads.length).toBeGreaterThan(0);
|
||||
await expect(page.getByText("Stale notice", { exact: true })).toHaveCount(0);
|
||||
});
|
||||
|
||||
for (const action of ["read", "dispatch"] as const) test(`late ${action} completion cannot restore another account's inbox`, async ({ page }) => {
|
||||
let releaseWrite!: () => void;
|
||||
let writeStarted = false;
|
||||
let writeFinished = false;
|
||||
const heldWrite = new Promise<void>((resolve) => { releaseWrite = resolve; });
|
||||
const reads: string[] = [];
|
||||
const notice = (owner: string) => ({ id: owner, subject: `Notice for ${owner}`, status: "pending", channel: "inbox",
|
||||
source_module: "test", source_resource_type: "fixture", recipient_id: owner, body_text: "Fixture", attempts: [], metadata: {}, payload: {},
|
||||
created_at: "2026-01-01T00:00:00Z", updated_at: "2026-01-01T00:00:00Z", priority: 0, attempt_count: 0 });
|
||||
await page.route("**/api/v1/notifications**", async (route) => {
|
||||
const owner = route.request().headers().authorization?.replace("Bearer ", "") ?? "filter-user";
|
||||
if (route.request().method() === "GET") {
|
||||
reads.push(owner);
|
||||
return route.fulfill({ json: { notifications: [notice(owner)] } });
|
||||
}
|
||||
writeStarted = true;
|
||||
await heldWrite;
|
||||
await route.fulfill({ json: action === "read" ? { ...notice(owner), read_at: "2026-01-02T00:00:00Z" } : { processed: 1, sent: 1, errors: [] } });
|
||||
writeFinished = true;
|
||||
});
|
||||
await page.goto("/?notification-filter&language=en&write");
|
||||
await expect(page.locator(".notifications-selection-list button")).toHaveCount(1);
|
||||
if (action === "read") await page.getByRole("button", { name: "Mark read", exact: true }).click();
|
||||
else {
|
||||
await page.getByRole("button", { name: "Dispatch pending", exact: true }).click();
|
||||
await page.getByRole("alertdialog").getByRole("button", { name: "Dispatch pending", exact: true }).click();
|
||||
}
|
||||
await expect.poll(() => writeStarted).toBe(true);
|
||||
await page.evaluate(() => window.dispatchEvent(new Event("conformance-notification-account")));
|
||||
await expect(page.locator(".notifications-selection-list")).toContainText("Notice for other-user");
|
||||
const readCount = reads.length;
|
||||
const writeResponse = page.waitForResponse((response) => response.request().method() !== "GET" && response.url().includes("/api/v1/notifications"));
|
||||
releaseWrite();
|
||||
await writeResponse;
|
||||
await expect.poll(() => writeFinished).toBe(true);
|
||||
await expect(page.locator(".notifications-selection-list")).not.toContainText("Notice for filter-user");
|
||||
await expect(page.getByRole("button", { name: "Mark read", exact: true })).toBeEnabled();
|
||||
expect(reads.length).toBe(readCount);
|
||||
});
|
||||
@@ -0,0 +1,77 @@
|
||||
import { expect, test } from "@playwright/test";
|
||||
|
||||
const generatorUrl = "**/PasswordGeneratorDialog.tsx*";
|
||||
|
||||
for (const language of ["en", "de"]) {
|
||||
test(`password generation loads only when opened and preserves cancel/use/focus (${language})`, async ({ page }) => {
|
||||
const generateLabel = language === "de" ? "Passwort generieren" : "Generate password";
|
||||
const cancelLabel = language === "de" ? "Abbrechen" : "Cancel";
|
||||
const useLabel = language === "de" ? "Passwort verwenden" : "Use password";
|
||||
const requests: string[] = [];
|
||||
page.on("request", (request) => {
|
||||
if (request.url().includes("/PasswordGeneratorDialog.tsx")) requests.push(request.url());
|
||||
});
|
||||
await page.goto(`/password-field.html?language=${language}`);
|
||||
const trigger = page.getByRole("button", { name: generateLabel, exact: true });
|
||||
await expect(trigger).toHaveCount(1);
|
||||
await expect(page.getByTestId("editable-password")).toHaveValue("fixture-unchanged-password");
|
||||
expect(requests).toHaveLength(0);
|
||||
|
||||
await trigger.click();
|
||||
const dialog = page.getByRole("dialog", { name: generateLabel, exact: true });
|
||||
await expect(dialog).toBeVisible();
|
||||
await expect.poll(() => requests.length).toBe(1);
|
||||
await expect(dialog.locator(".password-generator-result input")).toHaveValue(/^.{24}$/);
|
||||
await expect(page.getByTestId("password-changes")).toHaveText("0");
|
||||
await dialog.getByRole("button", { name: cancelLabel, exact: true }).click();
|
||||
await expect(dialog).toBeHidden();
|
||||
await expect(trigger).toBeFocused();
|
||||
await expect(page.getByTestId("editable-password")).toHaveValue("fixture-unchanged-password");
|
||||
|
||||
await trigger.click();
|
||||
await expect(dialog.locator(".password-generator-result input")).toHaveValue(/^.{24}$/);
|
||||
const candidate = await dialog.locator(".password-generator-result input").inputValue();
|
||||
await dialog.getByRole("button", { name: useLabel, exact: true }).click();
|
||||
await expect(dialog).toBeHidden();
|
||||
await expect(page.getByTestId("editable-password")).toHaveValue(candidate);
|
||||
await expect(page.getByTestId("editable-password")).toHaveAttribute("type", "password");
|
||||
await expect(page.getByTestId("password-changes")).toHaveText("1");
|
||||
await expect(trigger).toBeFocused();
|
||||
expect(requests).toHaveLength(1);
|
||||
});
|
||||
}
|
||||
|
||||
test("a pending generator load cannot reopen after generation becomes unavailable", async ({ page }) => {
|
||||
let releaseLoad!: () => void;
|
||||
const pending = new Promise<void>((resolve) => { releaseLoad = resolve; });
|
||||
await page.route(generatorUrl, async (route) => {
|
||||
await pending;
|
||||
await route.continue();
|
||||
});
|
||||
await page.goto("/password-field.html?language=en");
|
||||
await page.getByRole("button", { name: "Generate password", exact: true }).click();
|
||||
await expect(page.locator(".module-load-progress")).toBeVisible();
|
||||
await page.getByTestId("toggle-generator-access").click();
|
||||
releaseLoad();
|
||||
await expect(page.getByTestId("editable-password")).toBeDisabled();
|
||||
await expect(page.locator(".module-load-progress")).toBeHidden();
|
||||
await expect(page.getByRole("dialog")).toHaveCount(0);
|
||||
await page.getByTestId("toggle-generator-access").click();
|
||||
await expect(page.getByRole("dialog")).toHaveCount(0);
|
||||
await expect(page.getByTestId("password-changes")).toHaveText("0");
|
||||
await page.getByRole("button", { name: "Generate password", exact: true }).click();
|
||||
await expect(page.getByRole("dialog", { name: "Generate password", exact: true })).toBeVisible();
|
||||
});
|
||||
|
||||
test("generator chunk failure leaves ordinary password entry and reveal usable", async ({ page }) => {
|
||||
await page.route(generatorUrl, (route) => route.abort("failed"));
|
||||
await page.goto("/password-field.html?language=en");
|
||||
await page.getByRole("button", { name: "Generate password", exact: true }).click();
|
||||
await expect(page.locator(".module-load-error")).toBeVisible();
|
||||
await expect(page.getByTestId("editable-password")).toBeEnabled();
|
||||
await page.getByTestId("editable-password").fill("fixture-edited-password");
|
||||
await page.getByRole("button", { name: "Show password", exact: true }).first().click();
|
||||
await expect(page.getByTestId("editable-password")).toHaveAttribute("type", "text");
|
||||
await expect(page.getByTestId("editable-password")).toHaveValue("fixture-edited-password");
|
||||
await expect(page.getByRole("dialog")).toHaveCount(0);
|
||||
});
|
||||
@@ -0,0 +1,266 @@
|
||||
import { expect, test, type Page, type Request } from "@playwright/test";
|
||||
|
||||
const resources = [
|
||||
{ provider_id: "files", module_id: "files", resource_type: "file", label: "File", order: 1 },
|
||||
{ provider_id: "mail", module_id: "mail", resource_type: "message", label: "Message", order: 2 },
|
||||
{ provider_id: "tickets", module_id: "tickets", resource_type: "ticket", label: "Ticket", order: 3 },
|
||||
];
|
||||
function result(module: string, type: string, title: string) {
|
||||
return { provider_id: module, module_id: module, resource_type: type, resource_id: title, title, url: `/result/${title}`,
|
||||
highlights: [], breadcrumbs: [], metadata: {}, provenance: {}, score: 1 };
|
||||
}
|
||||
async function mockSearch(page: Page) {
|
||||
const reads: URLSearchParams[] = [];
|
||||
await page.route("**/api/v1/search/providers", (route) => route.fulfill({ json: { providers: [], resources } }));
|
||||
await page.route("**/api/v1/search?**", (route) => {
|
||||
const params = new URL(route.request().url()).searchParams;
|
||||
reads.push(params);
|
||||
const modules = params.getAll("module"), types = params.getAll("resource_type");
|
||||
return route.fulfill({ json: { query: params.get("q"), diagnostics: [], has_more: false,
|
||||
results: resources.filter((item) => (!modules.length || modules.includes(item.module_id)) && (!types.length || types.includes(item.resource_type)))
|
||||
.map((item) => result(item.module_id, item.resource_type, `${params.get("q")} ${item.label}`)) } });
|
||||
});
|
||||
return reads;
|
||||
}
|
||||
const popup = (page: Page) => page.locator(".multi-select-filter-popover");
|
||||
|
||||
for (const language of ["en", "de"]) test(`Search page shared filters preserve all, none, OR, and URL history (${language})`, async ({ page }) => {
|
||||
const errors: string[] = [];
|
||||
page.on("pageerror", (error) => errors.push(error.message));
|
||||
const reads = await mockSearch(page);
|
||||
await page.goto(`/?search-filters&language=${language}&q=permit&context=current&keep=1`);
|
||||
await expect(page.locator(".search-result")).toHaveCount(3);
|
||||
const trigger = page.locator(".multi-select-filter-trigger").first();
|
||||
await trigger.click();
|
||||
await expect(popup(page).getByRole("checkbox")).toHaveCount(3);
|
||||
await expect(popup(page).getByRole("checkbox").first()).toBeChecked();
|
||||
await popup(page).locator(".data-grid-list-filter-actions button").nth(1).click();
|
||||
await expect(page.locator(".search-result")).toHaveCount(0);
|
||||
expect(new URL(page.url()).searchParams.get("module_none")).toBe("1");
|
||||
const noneReadCount = reads.length;
|
||||
await popup(page).getByRole("checkbox", { name: "Files", exact: true }).click();
|
||||
await expect(page.locator(".search-result")).toHaveCount(1);
|
||||
expect(reads[reads.length - 1]?.getAll("module")).toEqual(["files"]);
|
||||
await popup(page).getByRole("checkbox", { name: "Mail", exact: true }).click();
|
||||
await expect(page.locator(".search-result")).toHaveCount(2);
|
||||
expect(reads[reads.length - 1]?.getAll("module")).toEqual(["files", "mail"]);
|
||||
expect(reads.length).toBeGreaterThan(noneReadCount);
|
||||
await page.keyboard.press("Escape");
|
||||
await expect(trigger).toBeFocused();
|
||||
await page.getByRole("button", { name: language === "de" ? "Filter zurücksetzen" : "Clear filters", exact: true }).click();
|
||||
await expect.poll(() => reads[reads.length - 1]?.getAll("module")).toEqual([]);
|
||||
expect(new URL(page.url()).searchParams.get("context")).toBe("current");
|
||||
expect(new URL(page.url()).searchParams.get("keep")).toBe("1");
|
||||
await page.goBack();
|
||||
await expect.poll(() => reads[reads.length - 1]?.getAll("module")).toEqual(["files", "mail"]);
|
||||
await page.goBack();
|
||||
await expect(page.locator(".search-result")).toHaveCount(1);
|
||||
await page.goBack();
|
||||
await expect(page.locator(".search-result")).toHaveCount(0);
|
||||
expect(new URL(page.url()).searchParams.get("module_none")).toBe("1");
|
||||
expect(errors).toEqual([]);
|
||||
});
|
||||
|
||||
test("legacy Search links retain explicit unknown values, and none takes precedence", async ({ page }) => {
|
||||
const reads = await mockSearch(page);
|
||||
await page.goto("/?search-filters&q=permit&module=retired&resource_type=file");
|
||||
await expect.poll(() => reads.length).toBe(1);
|
||||
expect(reads[0].getAll("module")).toEqual(["retired"]);
|
||||
await page.locator(".multi-select-filter-trigger").first().click();
|
||||
await expect(popup(page).getByRole("checkbox", { name: "Retired", exact: true })).toBeChecked();
|
||||
await page.goto("/?search-filters&q=permit&module=files&module_none=1");
|
||||
await expect(page.locator(".search-empty")).toBeVisible();
|
||||
expect(reads).toHaveLength(1);
|
||||
});
|
||||
|
||||
test("overlay context never broadens incompatible result types; nested filter Escape preserves Search", async ({ page }) => {
|
||||
const errors: string[] = [];
|
||||
page.on("pageerror", (error) => errors.push(error.message));
|
||||
const reads = await mockSearch(page);
|
||||
await page.goto("/files?search-filters&overlay&language=en");
|
||||
await expect(page.locator(".titlebar-search-button")).toBeVisible();
|
||||
await page.keyboard.press("F3");
|
||||
const overlay = page.locator(".search-overlay-dialog");
|
||||
await overlay.getByRole("searchbox").fill("permit");
|
||||
await expect(overlay.locator(".search-result")).toHaveCount(1);
|
||||
expect(reads[reads.length - 1]?.getAll("module")).toEqual(["files"]);
|
||||
expect(reads[reads.length - 1]?.getAll("resource_type")).toEqual(["file"]);
|
||||
await overlay.getByRole("tab", { name: "Everywhere", exact: true }).click();
|
||||
await expect(overlay.locator(".search-result")).toHaveCount(3);
|
||||
const types = overlay.locator(".multi-select-filter-trigger").nth(1);
|
||||
await types.click();
|
||||
await popup(page).locator(".data-grid-list-filter-actions button").nth(1).click();
|
||||
await popup(page).getByRole("checkbox", { name: "Message", exact: true }).check();
|
||||
await expect(overlay.locator(".search-result")).toHaveCount(1);
|
||||
await page.keyboard.press("Escape");
|
||||
await expect(popup(page)).toHaveCount(0);
|
||||
await expect(types).toBeFocused();
|
||||
await expect(overlay).toBeVisible();
|
||||
const count = reads.length;
|
||||
await overlay.getByRole("tab", { name: "Current files", exact: true }).click();
|
||||
await expect(overlay.locator(".search-empty")).toHaveText("No results for “permit”.");
|
||||
expect(reads.length).toBe(count);
|
||||
await overlay.getByRole("button", { name: "Clear filters", exact: true }).click();
|
||||
await expect(overlay.locator(".search-result")).toHaveCount(1);
|
||||
expect(reads[reads.length - 1]?.getAll("resource_type")).toEqual(["file"]);
|
||||
await page.keyboard.press("Escape");
|
||||
await expect(overlay).toHaveCount(0);
|
||||
expect(errors).toEqual([]);
|
||||
});
|
||||
|
||||
for (const overlay of [false, true]) test(`late cursor cannot append after filter change (${overlay ? "overlay" : "page"})`, async ({ page }) => {
|
||||
let release!: () => void;
|
||||
let cursorStarted = false;
|
||||
const held = new Promise<void>((resolve) => { release = resolve; });
|
||||
await mockSearch(page);
|
||||
await page.route("**/api/v1/search?**", async (route) => {
|
||||
const params = new URL(route.request().url()).searchParams;
|
||||
if (params.has("cursor")) { cursorStarted = true; await held; }
|
||||
return route.fulfill({ json: { query: "permit", diagnostics: [], results: [result("files", "file", params.has("cursor") ? "Old cursor result" : "First result")], next_cursor: params.has("cursor") ? null : "next", has_more: !params.has("cursor") } });
|
||||
});
|
||||
await page.goto(`/?search-filters&q=permit&language=en${overlay ? "&overlay" : ""}`);
|
||||
if (overlay) { await expect(page.locator(".titlebar-search-button")).toBeVisible(); await page.keyboard.press("Control+k"); await page.getByRole("searchbox").fill("permit"); }
|
||||
await page.getByRole("button", { name: "Load more", exact: true }).click();
|
||||
await expect.poll(() => cursorStarted).toBe(true);
|
||||
const failed = page.waitForEvent("requestfailed", { predicate: (request) => request.url().includes("cursor=") });
|
||||
await page.locator(".multi-select-filter-trigger").first().click();
|
||||
await popup(page).locator(".data-grid-list-filter-actions button").nth(1).click();
|
||||
await expect(page.locator(".search-result")).toHaveCount(0);
|
||||
release();
|
||||
await failed;
|
||||
await expect(page.locator(".search-result")).toHaveCount(0);
|
||||
await expect(page.locator(".search-empty")).toBeVisible();
|
||||
});
|
||||
|
||||
test("shared Search dropdown fits a narrow viewport and typing invalidates keyboard selection immediately", async ({ page }) => {
|
||||
await mockSearch(page);
|
||||
await page.setViewportSize({ width: 390, height: 700 });
|
||||
await page.goto("/?search-filters&overlay&language=de");
|
||||
await expect(page.locator(".titlebar-search-button")).toBeVisible();
|
||||
await page.keyboard.press("Control+k");
|
||||
const input = page.getByRole("searchbox");
|
||||
await input.fill("permit");
|
||||
await expect(page.locator(".search-result")).toHaveCount(3);
|
||||
await input.press("ArrowDown");
|
||||
await input.fill("changed");
|
||||
await input.press("Enter");
|
||||
expect(new URL(page.url()).pathname).toBe("/");
|
||||
await expect(page.locator(".search-result").first()).toContainText("changed");
|
||||
await page.locator(".multi-select-filter-trigger").first().click();
|
||||
const box = await popup(page).boundingBox();
|
||||
expect(box!.x).toBeGreaterThanOrEqual(0);
|
||||
expect(box!.x + box!.width).toBeLessThanOrEqual(390);
|
||||
await expect(popup(page).getByRole("button", { name: "Alle auswählen", exact: true })).toBeVisible();
|
||||
});
|
||||
|
||||
for (const overlay of [false, true]) test(`partial providers preserve safe results and successful pagination (${overlay ? "overlay" : "page"})`, async ({ page }) => {
|
||||
const errors: string[] = [];
|
||||
const reads: URLSearchParams[] = [];
|
||||
page.on("pageerror", (error) => errors.push(error.message));
|
||||
await page.route("**/api/v1/search/providers", (route) => route.fulfill({ json: { providers: [], resources } }));
|
||||
await page.route("**/api/v1/search?**", (route) => {
|
||||
const params = new URL(route.request().url()).searchParams;
|
||||
reads.push(params);
|
||||
const nextPage = params.has("cursor");
|
||||
return route.fulfill({ json: {
|
||||
query: params.get("q"),
|
||||
results: [result("files", "file", nextPage ? "Second authorized result" : "First authorized result")],
|
||||
diagnostics: [
|
||||
{ provider_id: "mail", message: "Mail provider temporarily unavailable." },
|
||||
...(nextPage ? [{ provider_id: "tickets", message: "Ticket provider returned partial results." }] : []),
|
||||
],
|
||||
next_cursor: nextPage ? null : "authorized-page-two", has_more: !nextPage,
|
||||
} });
|
||||
});
|
||||
await page.goto(`/?search-filters&q=permit&language=en${overlay ? "&overlay" : ""}`);
|
||||
if (overlay) {
|
||||
await expect(page.locator(".titlebar-search-button")).toBeVisible();
|
||||
await page.keyboard.press("F3");
|
||||
await page.getByRole("searchbox").fill("permit");
|
||||
}
|
||||
await expect(page.locator(".search-result")).toHaveCount(1);
|
||||
await expect(page.getByText("Mail provider temporarily unavailable.", { exact: true })).toBeVisible();
|
||||
await page.getByRole("button", { name: "Load more", exact: true }).click();
|
||||
await expect(page.locator(".search-result")).toHaveCount(2);
|
||||
await expect(page.locator(".search-result").first()).toContainText("First authorized result");
|
||||
await expect(page.locator(".search-result").nth(1)).toContainText("Second authorized result");
|
||||
await expect(page.getByText("Mail provider temporarily unavailable.", { exact: true })).toHaveCount(1);
|
||||
await expect(page.getByText("Ticket provider returned partial results.", { exact: true })).toBeVisible();
|
||||
await expect(page.getByRole("button", { name: "Load more", exact: true })).toHaveCount(0);
|
||||
expect(reads).toHaveLength(2);
|
||||
expect(reads[1].get("q")).toBe("permit");
|
||||
expect(reads[1].get("cursor")).toBe("authorized-page-two");
|
||||
expect(errors).toEqual([]);
|
||||
});
|
||||
|
||||
for (const sameToken of [false, true]) for (const overlay of [false, true]) test(`late query and catalogue cannot restore another account's Search (${overlay ? "overlay" : "page"}, ${sameToken ? "same token" : "new token"})`, async ({ page }) => {
|
||||
const errors: string[] = [];
|
||||
const oldStarted = new Set<string>();
|
||||
const oldFinished = new Set<string>();
|
||||
const oldFailed = new Set<string>();
|
||||
const oldRequests = new Set<Request>();
|
||||
const currentReads: string[] = [];
|
||||
const currentTokens: string[] = [];
|
||||
let accountSwitched = false;
|
||||
let releaseOld!: () => void;
|
||||
const oldResponses = new Promise<void>((resolve) => { releaseOld = resolve; });
|
||||
page.on("pageerror", (error) => errors.push(error.message));
|
||||
page.on("requestfailed", (request) => {
|
||||
if (oldRequests.has(request)) {
|
||||
oldFailed.add(new URL(request.url()).pathname.endsWith("/providers") ? "catalogue" : "query");
|
||||
}
|
||||
});
|
||||
await page.route("**/api/v1/search**", async (route) => {
|
||||
const oldAccount = !accountSwitched;
|
||||
const catalogue = new URL(route.request().url()).pathname.endsWith("/providers");
|
||||
const kind = catalogue ? "catalogue" : "query";
|
||||
if (oldAccount) { oldRequests.add(route.request()); oldStarted.add(kind); await oldResponses; }
|
||||
else {
|
||||
currentReads.push(kind);
|
||||
currentTokens.push(route.request().headers().authorization ?? "");
|
||||
}
|
||||
const json = catalogue ? {
|
||||
providers: [],
|
||||
resources: oldAccount
|
||||
? [{ provider_id: "retired", module_id: "retired_source", resource_type: "retired_record", label: "Retired record", order: 1 }]
|
||||
: [{ provider_id: "mail", module_id: "mail", resource_type: "message", label: "Current record", order: 1 }],
|
||||
} : {
|
||||
query: "permit", diagnostics: [], next_cursor: null, has_more: false,
|
||||
results: [oldAccount
|
||||
? result("retired_source", "retired_record", "Previous account result")
|
||||
: result("mail", "message", "Current account result")],
|
||||
};
|
||||
await route.fulfill({ json });
|
||||
if (oldAccount) oldFinished.add(kind);
|
||||
});
|
||||
await page.goto(`/?search-filters&q=permit&language=en${overlay ? "&overlay" : ""}${sameToken ? "&same-token" : ""}`);
|
||||
if (overlay) {
|
||||
await expect(page.locator(".titlebar-search-button")).toBeVisible();
|
||||
await page.keyboard.press("F3");
|
||||
await page.getByRole("searchbox").fill("permit");
|
||||
}
|
||||
await expect.poll(() => [...oldStarted].sort()).toEqual(["catalogue", "query"]);
|
||||
accountSwitched = true;
|
||||
await page.evaluate(() => window.dispatchEvent(new Event("conformance-search-account")));
|
||||
await expect(page.locator(".search-result")).toHaveCount(1);
|
||||
await expect(page.locator(".search-result")).toContainText("Current account result");
|
||||
await expect.poll(() => [...currentReads].sort()).toEqual(["catalogue", "query"]);
|
||||
await page.locator(".multi-select-filter-trigger").first().click();
|
||||
await expect(popup(page).getByRole("checkbox", { name: "Mail", exact: true })).toBeChecked();
|
||||
const readsAfterSwitch = currentReads.length;
|
||||
releaseOld();
|
||||
await expect.poll(() => [...oldFinished].sort()).toEqual(["catalogue", "query"]);
|
||||
await expect.poll(() => [...oldFailed].sort()).toEqual(["catalogue", "query"]);
|
||||
await expect(page.locator(".search-result")).toHaveCount(1);
|
||||
await expect(page.locator(".search-result")).toContainText("Current account result");
|
||||
await expect(page.getByText("Previous account result", { exact: true })).toHaveCount(0);
|
||||
await expect(popup(page).getByRole("checkbox")).toHaveCount(1);
|
||||
await expect(popup(page).getByRole("checkbox", { name: "Retired Source", exact: true })).toHaveCount(0);
|
||||
await page.keyboard.press("Escape");
|
||||
await page.locator(".multi-select-filter-trigger").nth(1).click();
|
||||
await expect(popup(page).getByRole("checkbox", { name: "Current record", exact: true })).toBeChecked();
|
||||
await expect(popup(page).getByRole("checkbox", { name: "Retired record", exact: true })).toHaveCount(0);
|
||||
expect(currentReads.length).toBe(readsAfterSwitch);
|
||||
expect(currentTokens).toEqual(Array(2).fill(sameToken ? "Bearer search-user" : "Bearer other-user"));
|
||||
expect(errors).toEqual([]);
|
||||
});
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 227 KiB After Width: | Height: | Size: 228 KiB |
@@ -19,6 +19,125 @@ async function expectNoAccessibilityViolations(page: import("@playwright/test").
|
||||
expect(violations).toEqual([]);
|
||||
}
|
||||
|
||||
for (const viewport of [
|
||||
{ name: "desktop", width: 1280, height: 900 },
|
||||
{ name: "mobile", width: 390, height: 844 }
|
||||
]) {
|
||||
test(`resident permit self-service is keyboard and accessibility conformant on ${viewport.name}`, async ({ page }) => {
|
||||
const journey = await mockPublicResidentPermitJourney(page);
|
||||
await page.setViewportSize(viewport);
|
||||
await page.goto("/forms/public/resident-parking-permit?theme=light");
|
||||
|
||||
await expect(page.getByRole("heading", { level: 1, name: "Anwohnerparkausweis beantragen" })).toBeVisible();
|
||||
await expectNoAccessibilityViolations(page);
|
||||
|
||||
const name = page.getByLabel("Name der antragstellenden Person");
|
||||
await name.focus();
|
||||
await page.keyboard.type("Ada Lovelace");
|
||||
await page.keyboard.press("Tab");
|
||||
await expect(page.getByLabel("E-Mail-Adresse")).toBeFocused();
|
||||
await page.keyboard.type("ada.lovelace@example.test");
|
||||
await page.keyboard.press("Tab");
|
||||
await expect(page.getByLabel("Hauptwohnsitz")).toBeFocused();
|
||||
await page.keyboard.type("Musterstraße 17, 10115 Berlin");
|
||||
await page.keyboard.press("Tab");
|
||||
await expect(page.getByLabel("Kfz-Kennzeichen")).toBeFocused();
|
||||
await page.keyboard.type("B-AL 1843");
|
||||
await page.keyboard.press("Tab");
|
||||
await expect(page.getByRole("button", { name: "Entwurf speichern" })).toBeFocused();
|
||||
await page.keyboard.press("Enter");
|
||||
await expect.poll(() => journey.savedValues()).toEqual({
|
||||
applicant_name: "Ada Lovelace",
|
||||
applicant_email: "ada.lovelace@example.test",
|
||||
residence_address: "Musterstraße 17, 10115 Berlin",
|
||||
licence_plate: "B-AL 1843"
|
||||
});
|
||||
|
||||
await page.getByRole("button", { name: "Absenden" }).click();
|
||||
const confirm = page.getByRole("alertdialog", { name: "Formular absenden" });
|
||||
await expect(confirm).toBeVisible();
|
||||
await expectNoAccessibilityViolations(page);
|
||||
await confirm.getByRole("button", { name: "Absenden" }).click();
|
||||
await expect(page.getByText("Übermittlung eingegangen")).toBeVisible();
|
||||
await expect(page.getByText("receipt-rpp-2026-0001")).toBeVisible();
|
||||
await expectNoHorizontalOverflow(page);
|
||||
});
|
||||
|
||||
test(`resident permit assisted intake preserves per-field provenance on ${viewport.name}`, async ({ page }) => {
|
||||
const journey = await mockAssistedResidentPermitJourney(page);
|
||||
await page.setViewportSize(viewport);
|
||||
await page.goto("/forms-runtime?theme=light");
|
||||
|
||||
await page.getByRole("button", { name: "Assistierte Erfassung" }).click();
|
||||
const startDialog = page.getByRole("dialog", { name: "Assistierte Erfassung starten" });
|
||||
await expect(startDialog).toBeVisible();
|
||||
await expectNoAccessibilityViolations(page);
|
||||
await startDialog.getByLabel("Referenz der betroffenen Partei").fill("party:resident-ada-lovelace");
|
||||
await startDialog.getByLabel("Referenz der zuständigen Funktion").fill("function:parking-permits");
|
||||
await startDialog.getByLabel("Zweck").fill("Anwohnerparkausweis beantragen");
|
||||
await startDialog.getByLabel("Referenz der Rechtsgrundlage").fill("law:resident-parking-permit");
|
||||
await startDialog.getByLabel("Barrierefreiheits- oder Kommunikationsunterstützung").fill("Leichte Sprache");
|
||||
const notice = startDialog.getByRole("checkbox", { name: "Datenschutz- und Verfahrenshinweis wurde erteilt" });
|
||||
await notice.focus();
|
||||
await page.keyboard.press("Space");
|
||||
await expect(notice).toBeChecked();
|
||||
await startDialog.getByLabel("Referenz der betroffenen Partei").focus();
|
||||
await page.keyboard.press("Tab");
|
||||
await expect(page.locator(":focus")).toHaveAttribute("aria-label", "Feldhilfe anzeigen");
|
||||
await page.keyboard.press("Tab");
|
||||
await expect(startDialog.getByLabel("Referenz der vertretenen Partei")).toBeFocused();
|
||||
await startDialog.getByRole("button", { name: "Sitzung starten" }).click();
|
||||
|
||||
await expect(page).toHaveURL(/\/forms-runtime\/assisted-rpp-1$/);
|
||||
await expect(page.getByRole("heading", { level: 1, name: "Anwohnerparkausweis beantragen" })).toBeVisible();
|
||||
await page.getByLabel("Name der antragstellenden Person").fill("Ada Lovelace");
|
||||
await page.getByLabel("E-Mail-Adresse").fill("ada.lovelace@example.test");
|
||||
await page.getByLabel("Hauptwohnsitz").fill("Musterstraße 17, 10115 Berlin");
|
||||
await page.getByLabel("Kfz-Kennzeichen").fill("B-AL 1843");
|
||||
await page.getByLabel("Änderungsgrund").fill("Angaben gemeinsam mit der antragstellenden Person erfasst.");
|
||||
await page.getByRole("button", { name: "Entwurf speichern" }).click();
|
||||
|
||||
await page.getByRole("button", { name: "Rücklesen und absenden" }).click();
|
||||
const readback = page.getByRole("dialog", { name: "Assistiertes Rücklesen erfassen" });
|
||||
await expect(readback).toBeVisible();
|
||||
await expect(readback.getByRole("group", { name: "Hauptwohnsitz" })).toBeVisible();
|
||||
await expectNoAccessibilityViolations(page);
|
||||
|
||||
const addressSource = readback.getByRole("group", { name: "Hauptwohnsitz" });
|
||||
await addressSource.getByLabel("Wertquelle").selectOption("document");
|
||||
await addressSource.getByLabel("Quellenvertrauen").selectOption("verified");
|
||||
await addressSource.getByLabel("Erklärende Partei oder Quellenreferenz").fill("files:residence-proof-2026");
|
||||
const plateSource = readback.getByRole("group", { name: "Kfz-Kennzeichen" });
|
||||
await plateSource.getByLabel("Wertquelle").focus();
|
||||
await page.keyboard.press("ArrowDown");
|
||||
await page.keyboard.press("ArrowDown");
|
||||
await page.keyboard.press("ArrowDown");
|
||||
await page.keyboard.press("Enter");
|
||||
await plateSource.getByLabel("Quellenvertrauen").selectOption("verified");
|
||||
await plateSource.getByLabel("Erklärende Partei oder Quellenreferenz").fill("register:vehicle-B-AL-1843");
|
||||
await readback.getByRole("button", { name: "Erfassen und fortfahren" }).click();
|
||||
|
||||
await expect.poll(() => journey.confirmationSources()).toMatchObject({
|
||||
applicant_name: { source: "person_statement", confidence: "stated" },
|
||||
residence_address: {
|
||||
source: "document",
|
||||
confidence: "verified",
|
||||
declared_by_ref: "files:residence-proof-2026"
|
||||
},
|
||||
licence_plate: {
|
||||
source: "system",
|
||||
confidence: "verified",
|
||||
declared_by_ref: "register:vehicle-B-AL-1843"
|
||||
}
|
||||
});
|
||||
const submit = page.getByRole("alertdialog", { name: "Formular absenden" });
|
||||
await submit.getByRole("button", { name: "Absenden" }).click();
|
||||
await expect(page.getByText("receipt-assisted-rpp-2026-0001")).toBeVisible();
|
||||
await expect(page.getByText("Rücklesen erfasst")).toBeVisible();
|
||||
await expectNoHorizontalOverflow(page);
|
||||
});
|
||||
}
|
||||
|
||||
test("shared components remain accessible and keyboard operable", async ({ page }) => {
|
||||
await page.goto("/?theme=light");
|
||||
await expect(page.getByRole("heading", { level: 1, name: "Zentrale GovOPlaN-Oberflächen" })).toBeVisible();
|
||||
@@ -33,22 +152,22 @@ test("shared components remain accessible and keyboard operable", async ({ page
|
||||
await expect(editorActions.locator("[data-page-action-separation='destructive']")).toHaveCSS("border-left-width", "2px");
|
||||
const actionSlots = await editorActions.locator("[data-page-action-slot]").evaluateAll((elements) => elements.map((element) => element.getAttribute("data-page-action-slot")));
|
||||
expect(actionSlots).toEqual([
|
||||
"reload",
|
||||
"context",
|
||||
"reload",
|
||||
"destructive",
|
||||
"discard",
|
||||
"save"
|
||||
]);
|
||||
await expect(editorActions.getByRole("button")).toHaveText([
|
||||
"Neu laden",
|
||||
"Vorschau öffnen",
|
||||
"Neu laden",
|
||||
"Löschen",
|
||||
"Verwerfen",
|
||||
"Änderungen speichern"
|
||||
]);
|
||||
await editorActions.getByRole("button", { name: "Neu laden" }).focus();
|
||||
await editorActions.getByRole("button", { name: "Vorschau öffnen" }).focus();
|
||||
await page.keyboard.press("Tab");
|
||||
await expect(editorActions.getByRole("button", { name: "Vorschau öffnen" })).toBeFocused();
|
||||
await expect(editorActions.getByRole("button", { name: "Neu laden" })).toBeFocused();
|
||||
await page.keyboard.press("Tab");
|
||||
await expect(editorActions.locator(".disabled-action-tooltip")).toBeFocused();
|
||||
await expect(page.getByRole("tooltip")).toContainText("Nur die federführende Stelle");
|
||||
@@ -176,6 +295,33 @@ test("View focus has a deliberate permission-derived all-tools escape", async ({
|
||||
await expect(page.getByRole("button", { name: "Messages" })).toHaveCount(0);
|
||||
});
|
||||
|
||||
test("product navigation hides package topology behind stable bilingual destinations", async ({ page }) => {
|
||||
await page.setViewportSize({ width: 1280, height: 900 });
|
||||
await page.goto("/?theme=light&product-navigation=1");
|
||||
await page.getByRole("button", { name: "Expand navigation" }).click();
|
||||
|
||||
const primary = page.locator(".icon-nav > .icon-nav-group");
|
||||
await expect(primary.getByRole("link")).toHaveText([
|
||||
"Arbeit",
|
||||
"Dateien",
|
||||
"Nachrichten",
|
||||
"Kalender"
|
||||
]);
|
||||
await expect(primary.getByRole("link", { name: /Tasks|Files|Mail|Postbox|Calendar/ })).toHaveCount(0);
|
||||
|
||||
const allTools = page.locator("[data-product-navigation='all-tools']");
|
||||
await expect(allTools.getByText("Alle verfügbaren Werkzeuge", { exact: true })).toBeVisible();
|
||||
await allTools.locator("summary").click();
|
||||
await expect(allTools.getByRole("link")).toHaveText([
|
||||
"Tasks",
|
||||
"Files",
|
||||
"Mail",
|
||||
"Postbox",
|
||||
"Calendar"
|
||||
]);
|
||||
await expectNoAccessibilityViolations(page);
|
||||
});
|
||||
|
||||
test("a stale View focus falls back safely in a sparse optional-module catalogue", async ({ page }) => {
|
||||
await page.route("**/api/v1/quick-access/effective*", async (route) => {
|
||||
await route.fulfill({
|
||||
@@ -229,6 +375,301 @@ test("narrow layout preserves task order without horizontal overflow", async ({
|
||||
await expect(page.locator("[data-conformance-id='shared-ui-lab']")).toHaveScreenshot("shared-ui-light-narrow.png", { animations: "disabled", maxDiffPixelRatio: 0.005 });
|
||||
});
|
||||
|
||||
async function expectNoHorizontalOverflow(page: import("@playwright/test").Page) {
|
||||
const overflowing = await page.evaluate(() => Array.from(document.querySelectorAll<HTMLElement>("body *"))
|
||||
.filter((element) => {
|
||||
const style = window.getComputedStyle(element);
|
||||
return style.display !== "none" && style.visibility !== "hidden";
|
||||
})
|
||||
.map((element) => {
|
||||
const rect = element.getBoundingClientRect();
|
||||
return {
|
||||
element: `${element.tagName.toLowerCase()}.${Array.from(element.classList).join(".")}`,
|
||||
left: Math.round(rect.left),
|
||||
right: Math.round(rect.right)
|
||||
};
|
||||
})
|
||||
.filter(({ left, right }) => left < -1 || right > window.innerWidth + 1)
|
||||
.slice(0, 20));
|
||||
expect(overflowing).toEqual([]);
|
||||
}
|
||||
|
||||
async function mockPublicResidentPermitJourney(page: import("@playwright/test").Page) {
|
||||
let current = residentPermitInstance("public-rpp-1", "started", 1, {});
|
||||
let savedValues: Record<string, unknown> = {};
|
||||
|
||||
await page.route("**/api/v1/forms-runtime/**", async (route) => {
|
||||
const request = route.request();
|
||||
const path = new URL(request.url()).pathname;
|
||||
const method = request.method();
|
||||
if (path.endsWith("/public/profiles/resident-parking-permit/start") && method === "POST") {
|
||||
return fulfillJson(route, {
|
||||
session_id: "session-public-rpp-1",
|
||||
mode: "anonymous",
|
||||
status: "active",
|
||||
expires_at: "2026-08-25T10:00:00Z",
|
||||
instance: current,
|
||||
token: "public-rpp-token",
|
||||
replayed: false
|
||||
});
|
||||
}
|
||||
if (path.endsWith("/public/intake") && method === "GET") {
|
||||
return fulfillJson(route, { instance: current, definition: residentPermitDefinition() });
|
||||
}
|
||||
if (path.endsWith("/public/intake") && method === "PATCH") {
|
||||
const payload = request.postDataJSON() as { values: Record<string, unknown> };
|
||||
savedValues = payload.values;
|
||||
current = residentPermitInstance("public-rpp-1", "draft", 2, payload.values);
|
||||
return fulfillJson(route, current);
|
||||
}
|
||||
if (path.endsWith("/public/intake/submit") && method === "POST") {
|
||||
const payload = request.postDataJSON() as { values: Record<string, unknown> };
|
||||
current = {
|
||||
...residentPermitInstance("public-rpp-1", "submitted", 3, payload.values),
|
||||
receipt_id: "receipt-rpp-2026-0001"
|
||||
};
|
||||
return fulfillJson(route, current);
|
||||
}
|
||||
return route.abort("failed");
|
||||
});
|
||||
|
||||
return { savedValues: () => savedValues };
|
||||
}
|
||||
|
||||
async function mockAssistedResidentPermitJourney(page: import("@playwright/test").Page) {
|
||||
let current = residentPermitInstance("assisted-rpp-1", "started", 1, {}, true);
|
||||
let confirmationSources: Record<string, unknown> = {};
|
||||
let confirmations: unknown[] = [];
|
||||
|
||||
await page.route("**/api/v1/forms-runtime/**", async (route) => {
|
||||
const request = route.request();
|
||||
const path = new URL(request.url()).pathname;
|
||||
const method = request.method();
|
||||
|
||||
if (path.endsWith("/instances") && method === "GET") {
|
||||
return fulfillJson(route, { instances: [], total: 0, offset: 0, limit: 200 });
|
||||
}
|
||||
if (path.endsWith("/assisted-intake/profiles") && method === "GET") {
|
||||
return fulfillJson(route, { profiles: [{
|
||||
profile_id: "assisted-profile-rpp",
|
||||
public_id: "resident-parking-permit",
|
||||
definition_ref: residentPermitDefinition().reference,
|
||||
mode: "assisted",
|
||||
enabled: true,
|
||||
revision: 1,
|
||||
draft_ttl_seconds: 2_592_000,
|
||||
invitation_ttl_seconds: 1_209_600,
|
||||
rate_limit_per_minute: 60,
|
||||
metadata: { definition_title: "Anwohnerparkausweis beantragen" }
|
||||
}] });
|
||||
}
|
||||
if (path.endsWith("/assisted-intake/start") && method === "POST") {
|
||||
return fulfillJson(route, {
|
||||
session_id: "assisted-session-rpp-1",
|
||||
mode: "assisted",
|
||||
status: "active",
|
||||
expires_at: "2026-08-25T10:00:00Z",
|
||||
instance: current,
|
||||
token: null,
|
||||
replayed: false
|
||||
});
|
||||
}
|
||||
if (path.endsWith("/instances/assisted-rpp-1/definition") && method === "GET") {
|
||||
return fulfillJson(route, residentPermitDefinition());
|
||||
}
|
||||
if (path.endsWith("/instances/assisted-rpp-1/history") && method === "GET") {
|
||||
return fulfillJson(route, { revisions: [current] });
|
||||
}
|
||||
if (path.endsWith("/instances/assisted-rpp-1/events") && method === "GET") {
|
||||
return fulfillJson(route, { events: [{
|
||||
event_id: `event-${current.revision}`,
|
||||
event_type: current.status === "submitted" ? "submitted" : "draft_saved",
|
||||
instance_revision: current.revision,
|
||||
status: current.status,
|
||||
occurred_at: current.recorded_at,
|
||||
actor_id: "operator-1",
|
||||
payload: {}
|
||||
}] });
|
||||
}
|
||||
if (path.endsWith("/instances/assisted-rpp-1/handoffs") && method === "GET") {
|
||||
return fulfillJson(route, { handoffs: [] });
|
||||
}
|
||||
if (path.endsWith("/instances/assisted-rpp-1/assisted-confirmations") && method === "GET") {
|
||||
return fulfillJson(route, { confirmations });
|
||||
}
|
||||
if (path.endsWith("/instances/assisted-rpp-1/assisted-confirmations") && method === "POST") {
|
||||
const payload = request.postDataJSON() as { field_sources: Record<string, unknown> };
|
||||
confirmationSources = payload.field_sources;
|
||||
const confirmation = {
|
||||
confirmation_id: "confirmation-rpp-1",
|
||||
instance_id: "assisted-rpp-1",
|
||||
instance_revision: current.revision,
|
||||
outcome: "confirmed",
|
||||
method: "spoken_readback",
|
||||
confirmed_by_ref: "party:resident-ada-lovelace",
|
||||
operator_actor_id: "operator-1",
|
||||
confirmed_at: "2026-08-24T10:10:00Z",
|
||||
payload_sha256: "a".repeat(64),
|
||||
correction_note: null,
|
||||
metadata: { field_sources: confirmationSources }
|
||||
};
|
||||
confirmations = [confirmation];
|
||||
return fulfillJson(route, confirmation);
|
||||
}
|
||||
if (path.endsWith("/instances/assisted-rpp-1/submit") && method === "POST") {
|
||||
const payload = request.postDataJSON() as { values: Record<string, unknown> };
|
||||
current = {
|
||||
...residentPermitInstance("assisted-rpp-1", "submitted", current.revision + 1, payload.values, true),
|
||||
receipt_id: "receipt-assisted-rpp-2026-0001"
|
||||
};
|
||||
return fulfillJson(route, current);
|
||||
}
|
||||
if (path.endsWith("/instances/assisted-rpp-1") && method === "PATCH") {
|
||||
const payload = request.postDataJSON() as { values: Record<string, unknown> };
|
||||
current = residentPermitInstance("assisted-rpp-1", "draft", current.revision + 1, payload.values, true);
|
||||
return fulfillJson(route, current);
|
||||
}
|
||||
if (path.endsWith("/instances/assisted-rpp-1") && method === "GET") {
|
||||
return fulfillJson(route, current);
|
||||
}
|
||||
return route.abort("failed");
|
||||
});
|
||||
|
||||
return { confirmationSources: () => confirmationSources };
|
||||
}
|
||||
|
||||
function residentPermitDefinition() {
|
||||
return {
|
||||
reference: {
|
||||
kind: "form",
|
||||
owner_module: "forms",
|
||||
object_id: "resident-parking-permit-application",
|
||||
tenant_id: "tenant-1",
|
||||
version: "3",
|
||||
label: "Anwohnerparkausweis beantragen"
|
||||
},
|
||||
key: "resident_parking_permit.apply",
|
||||
temporal: { revision: "3", recorded_at: "2026-08-24T10:00:00Z" },
|
||||
title: "Resident parking permit application",
|
||||
description: "Apply digitally or together with an authorized service worker.",
|
||||
fields: [
|
||||
residentPermitField("applicant_name", "Applicant name", "text", { min_length: 2, max_length: 200 }),
|
||||
residentPermitField("applicant_email", "Applicant email", "email", { format: "email" }),
|
||||
residentPermitField("residence_address", "Primary residence", "text", { max_length: 500 }),
|
||||
residentPermitField("licence_plate", "Licence plate", "text", { max_length: 20 })
|
||||
],
|
||||
publication_state: "published",
|
||||
allow_drafts: true,
|
||||
max_attachments: 0,
|
||||
signature_requirement: "none",
|
||||
policy_refs: ["law:resident-parking-permit"],
|
||||
handoff_kinds: [],
|
||||
fallback_locale: "de",
|
||||
localizations: [{
|
||||
locale: "de",
|
||||
title: "Anwohnerparkausweis beantragen",
|
||||
description: "Beantragen Sie den Anwohnerparkausweis digital oder gemeinsam mit einer berechtigten Servicestelle.",
|
||||
field_labels: {
|
||||
applicant_name: "Name der antragstellenden Person",
|
||||
applicant_email: "E-Mail-Adresse",
|
||||
residence_address: "Hauptwohnsitz",
|
||||
licence_plate: "Kfz-Kennzeichen"
|
||||
},
|
||||
field_help_texts: {},
|
||||
option_labels: {},
|
||||
page_titles: {},
|
||||
section_titles: {}
|
||||
}]
|
||||
};
|
||||
}
|
||||
|
||||
function residentPermitField(
|
||||
key: string,
|
||||
label: string,
|
||||
valueType: "text" | "email",
|
||||
constraints: Record<string, unknown>
|
||||
) {
|
||||
return {
|
||||
key,
|
||||
label,
|
||||
value_type: valueType,
|
||||
required: true,
|
||||
help_text: null,
|
||||
options: [],
|
||||
constraints,
|
||||
default_value: null,
|
||||
visibility_condition: null
|
||||
};
|
||||
}
|
||||
|
||||
function residentPermitInstance(
|
||||
instanceId: string,
|
||||
status: string,
|
||||
revision: number,
|
||||
values: Record<string, unknown>,
|
||||
assisted = false
|
||||
) {
|
||||
return {
|
||||
reference: {
|
||||
kind: "form_instance",
|
||||
owner_module: "forms_runtime",
|
||||
object_id: instanceId,
|
||||
tenant_id: "tenant-1",
|
||||
version: String(revision),
|
||||
label: "Anwohnerparkausweis beantragen"
|
||||
},
|
||||
tenant_id: "tenant-1",
|
||||
instance_id: instanceId,
|
||||
revision,
|
||||
status,
|
||||
definition_ref: residentPermitDefinition().reference,
|
||||
values,
|
||||
validation_results: [],
|
||||
attachment_refs: [],
|
||||
signature_refs: [],
|
||||
handoff_refs: [],
|
||||
service_ref: null,
|
||||
receipt_id: null as string | null,
|
||||
recorded_at: "2026-08-24T10:00:00Z",
|
||||
change_reason: revision === 1 ? "Assisted session started." : "Draft saved.",
|
||||
created_by: "operator-1",
|
||||
changed_by: "operator-1",
|
||||
metadata: assisted ? {
|
||||
intake: {
|
||||
session_id: "assisted-session-rpp-1",
|
||||
profile_id: "assisted-profile-rpp",
|
||||
mode: "assisted",
|
||||
channel: "counter",
|
||||
affected_party_ref: "party:resident-ada-lovelace",
|
||||
represented_party_ref: null,
|
||||
authority_basis: "self",
|
||||
purpose: "Anwohnerparkausweis beantragen",
|
||||
legal_basis_ref: "law:resident-parking-permit",
|
||||
consent_basis: "in-person-confirmation",
|
||||
notice_given: true,
|
||||
responsible_function_ref: "function:parking-permits",
|
||||
language: "de",
|
||||
accessibility_needs: ["Leichte Sprache"],
|
||||
field_sources: {},
|
||||
operator: { actor_id: "operator-1", auth_method: "session" }
|
||||
}
|
||||
} : {},
|
||||
status_access: null,
|
||||
replayed: false
|
||||
};
|
||||
}
|
||||
|
||||
async function fulfillJson(
|
||||
route: import("@playwright/test").Route,
|
||||
body: unknown
|
||||
) {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify(body)
|
||||
});
|
||||
}
|
||||
|
||||
function quickAccessPayload(includeMessages: boolean) {
|
||||
const files = {
|
||||
id: "files",
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
import { expect, test } from "@playwright/test";
|
||||
|
||||
for (const mode of ["visual", "source"] as const) {
|
||||
test(`rich-text ${mode} editor stays clean until a real content edit`, async ({ page }) => {
|
||||
const pageErrors: string[] = [];
|
||||
page.on("pageerror", (error) => pageErrors.push(error.message));
|
||||
await page.goto(`/?wysiwyg-lifecycle&mode=${mode}`);
|
||||
const changeCount = page.getByTestId("wysiwyg-change-count");
|
||||
const value = page.getByTestId("wysiwyg-controlled-value");
|
||||
const editor = mode === "visual"
|
||||
? page.locator(".wysiwyg-prosemirror")
|
||||
: page.locator(".wysiwyg-editor-source");
|
||||
const original = mode === "visual"
|
||||
? "<p>Legacy <i>template</i></p>"
|
||||
: '<table style="width: 100%"><tbody><tr><td>Legacy template</td></tr></tbody></table>';
|
||||
|
||||
await expect(editor).toBeVisible();
|
||||
// <i> is supported visually but normalized to <em> inside Tiptap. That
|
||||
// normalization must not leak into controlled content without a real edit.
|
||||
if (mode === "visual") await expect(editor.locator("em")).toHaveText("template");
|
||||
await expect(value).toHaveText(original);
|
||||
await expect(changeCount).toHaveText("0");
|
||||
|
||||
await page.getByRole("button", { name: "Toggle read-only", exact: true }).click();
|
||||
if (mode === "visual") await expect(editor).toHaveAttribute("contenteditable", "false");
|
||||
else await expect(editor).toBeDisabled();
|
||||
await expect(value).toHaveText(original);
|
||||
await expect(changeCount).toHaveText("0");
|
||||
await page.getByRole("button", { name: "Toggle read-only", exact: true }).click();
|
||||
|
||||
await page.getByRole("button", { name: "Load another value", exact: true }).click();
|
||||
await expect(value).toHaveText(original.replace("Legacy", "Reloaded"));
|
||||
await expect(changeCount).toHaveText("0");
|
||||
|
||||
await page.getByRole("button", { name: "Toggle editor mount", exact: true }).click();
|
||||
await expect(editor).toHaveCount(0);
|
||||
await page.getByRole("button", { name: "Toggle editor mount", exact: true }).click();
|
||||
await expect(editor).toBeVisible();
|
||||
await expect(value).toHaveText(original.replace("Legacy", "Reloaded"));
|
||||
await expect(changeCount).toHaveText("0");
|
||||
|
||||
if (mode === "visual") {
|
||||
await page.getByRole("tab", { name: "Source fixture mode", exact: true }).click();
|
||||
await expect(page.locator(".wysiwyg-editor-source")).toHaveValue(original.replace("Legacy", "Reloaded"));
|
||||
await expect(changeCount).toHaveText("0");
|
||||
await page.getByRole("tab", { name: "Visual fixture mode", exact: true }).click();
|
||||
await expect(editor).toBeVisible();
|
||||
await expect(changeCount).toHaveText("0");
|
||||
}
|
||||
|
||||
await editor.fill(mode === "source" ? "<p>Edited content</p>" : "Edited content");
|
||||
await expect.poll(async () => Number(await changeCount.textContent())).toBeGreaterThan(0);
|
||||
await expect(value).toContainText("Edited content");
|
||||
expect(pageErrors).toEqual([]);
|
||||
});
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user