diff --git a/docs/e2e/README.md b/docs/e2e/README.md index 0d962323..61e00fba 100644 --- a/docs/e2e/README.md +++ b/docs/e2e/README.md @@ -62,6 +62,12 @@ For debugging against a server you started yourself, set `E2E_PUBLIC_BASE_URL` as needed. Do not use reuse mode for CI or for regression runs that need a clean database. +Local trace and video capture are opt-in because a complete run keeps many SSE +connections in one worker and can otherwise accumulate gigabytes of temporary +artifacts before Playwright discards passing tests. Set `E2E_TRACE=1` and/or +`E2E_VIDEO=1` for a focused debugging run. CI still records trace/video on the +first retry automatically. + ### Suite structure - **`tests/e2e/helpers/`** — small, user-behaviour-shaped helpers (setup/login, @@ -71,19 +77,28 @@ regression runs that need a clean database. disposable DB is set up once per run, so first-run setup happens here (proving SETUP-001) and the owner's authenticated `storageState` is saved. Every spec depends on it. +- **`dashboard.e2e.ts` preflight** — runs immediately after setup, before any + persona/plugin/content mutations, so clean-install onboarding facts stay + deterministic. The normal E2E project excludes this file to avoid a second run. +- **`account-persona.setup.ts`** — the next setup project creates the + dedicated Admin used by `account.e2e.ts`. Password changes, MFA changes, and + sign-out-everywhere flows revoke other sessions for their target account; the + persona keeps those intentional side effects away from the saved owner session. - **Session rule.** Specs default to the shared owner `storageState` (fast). Specs that run a **step-up-gated action** (publish, profile basics, etc.) or **sign out** rotate the session token server-side, so they opt into - `ANONYMOUS_STATE` and `login()` fresh — otherwise they would invalidate the - shared state for later specs. + `ANONYMOUS_STATE` and log in fresh — otherwise they would invalidate the shared + state for later specs. Account-global self-management specs log in as the + account persona, never the owner. - **Selectors.** Durable user-facing selectors first (roles, labels, accessible names). `data-testid` only for stable editor/canvas controls where an accessible name is not practical (canvas notch, toolbar publish actions, the step-up dialog). - **Isolation.** With `workers: 1` all specs share one database; each spec works on its own uniquely-named page/post (only the core lifecycle spec edits the - homepage), and publish→assert happens within a single test so cross-spec order - never matters. + homepage), clean-install dashboard coverage runs before shared mutations, + account-global security changes stay on the account persona, fixture-owned AI + defaults are removed, and publish→assert happens within a single test. ### Automated coverage map @@ -111,7 +126,7 @@ Playwright specs unless a focused Bun test path is included: | CAP-005 (plugin read/install/configure/lifecycle/schedule/pack + AI chat rail + provider/audit tab gates + AI write-tool filtering) | `capabilities.e2e.ts`, `ai.e2e.ts` | | ADMIN-002 (profile basics), ACCOUNT-001 (display name + profile API edges + mobile cancel/no-op), ACCOUNT-002 (avatar upload/removal/invalid upload feedback/API edges/storage error/mobile layout), ADMIN-003 (MFA setup cancel), ACCOUNT-003 (password change + mobile dialog), AUTH-002 (MFA TOTP/mobile challenge/pending-session API edges/unknown-cookie rejection/empty+wrong-code feedback/recovery-code login/reuse rejection), AUTH-004 (active-device sign-out + mobile table + session API edges), ACCOUNT-004 (MFA invalid-code feedback, QR fallback, enable/login/recovery regenerate/disable + mobile setup/login containment), ACCOUNT-005 (step-up window + mobile controls + disabled/invalid API edges), AUTH-006 (failed/successful login activity feed, disposable-account lockout/rate-limit browser flow, and suspicious activity banner) | `account.e2e.ts`, `src/__tests__/admin/accountPage.test.tsx`, `src/__tests__/server/accountSecurity.test.ts`, `src/__tests__/server/authSessions.test.ts`, `src/__tests__/server/authSessionEdgeCases.test.ts` | | PLUGIN-001 (ZIP package install via packaged fixtures), PLUGIN-002 (enable/disable/remove lifecycle), PLUGIN-003 (settings/secrets), PLUGIN-004 (packaged admin pages/resources/runtime route), PLUGIN-005 (schedules), PLUGIN-006 (pack install/re-sync), PLUGIN-008 (invalid manifest upload recovery) | `plugins.e2e.ts` | -| AI-001 (Ollama credential create/delete + offline default guard), AI-002 (Data-scope default save/reload/clear), AI-003 (Site chat history load/new/delete), AI-004 (fixture-backed Site assistant streaming), AI-005 (browser `read_document` tool-result bridge), AI-006 (Audit tab rollups from streamed usage) | `ai.e2e.ts` | +| AI-001 (Ollama credential create/delete + offline default guard), AI-002 (Data-scope default save/reload/clear), AI-003 (Site chat history load/new/delete), AI-004 (fixture-backed Site assistant streaming), AI-005 (browser `site_read_document` tool-result bridge), AI-006 (Audit tab rollups from streamed usage) | `ai.e2e.ts` | | A11Y-001, A11Y-002, RESP-001, RESP-002 | `accessibility.e2e.ts` | | PERF-001, PERF-002 (performance/reliability smokes) | `performance.e2e.ts` | | REL-001 | `reliability.e2e.ts` | @@ -377,7 +392,7 @@ durable assertion brittle: are automated in `ai.e2e.ts`; AI-002 Data-scope default save/reload/clear coverage is automated there too. AI-003 Site chat history load/new/delete, AI-004 fixture-backed Site assistant streaming, AI-005 browser - `read_document` tool-result bridge, CAP-005 request-level AI write-tool filtering, and AI-006 Audit tab rollups from that + `site_read_document` tool-result bridge, CAP-005 request-level AI write-tool filtering, and AI-006 Audit tab rollups from that streamed usage are also automated in `ai.e2e.ts`. AI-007 direct Anthropic/OpenAI/Ollama/OpenRouter driver mapping and pricing coverage is automated in focused Bun tests; live-provider network behaviour remains diff --git a/docs/e2e/feature-matrix.md b/docs/e2e/feature-matrix.md index 0fa48bc9..191f608d 100644 --- a/docs/e2e/feature-matrix.md +++ b/docs/e2e/feature-matrix.md @@ -69,7 +69,7 @@ USERS-001 note: non-owner user creation, step-up protection, edit, suspend, acti AUTH-003 note: account-menu logout/login is covered in the core owner lifecycle, and `auth.e2e.ts` automates stale-tab session invalidation by cloning a live session into a second browser context, signing out in the first, then verifying the stale context is forced back to Admin Login on the next authenticated admin route without the stale Site route logging the module-inserter Unauthorized preference-load error. The same spec covers 390px mobile account-menu containment and sign-out reachability. `authSessionEdgeCases.test.ts` covers a repeated stale `POST /logout` returning `{ ok: true }`, clearing the cookie again, and leaving `/me` unauthorized. -ACCOUNT-003 note: password form validation, exact-minimum local validation, mobile password card/dialog layout, step-up-gated password rotation, old-password rejection, new-password login, and shared owner credential restoration are automated in `account.e2e.ts`; multi-session revocation and autofill variants remain lower-level or future browser coverage. +ACCOUNT-003 note: password form validation, exact-minimum local validation, mobile password card/dialog layout, step-up-gated password rotation, old-password rejection, new-password login, and dedicated account-persona credential restoration are automated in `account.e2e.ts`; multi-session revocation and autofill variants remain lower-level or future browser coverage. The account persona isolates password, MFA, and sign-out-everywhere side effects from the shared owner session used by later specs. AUTH-002 note: TOTP MFA login, mobile MFA challenge layout, empty-code required-field validation, wrong-code feedback, recovery-code login burn, and recovery-code reuse rejection are automated in `account.e2e.ts`; pending-cookie API denial, expired pending-session rejection, and unknown pending-cookie rejection are covered in `accountSecurity.test.ts`. @@ -97,7 +97,7 @@ DASH-001 note: `dashboard.e2e.ts` verifies the default owner dashboard route, Ov DASH-002 note: `dashboard.e2e.ts` verifies customize mode, the Block library, adding the built-in AI usage widget, server-backed `dashboard-layout` preference persistence, reload restoration, grid drag move, right-edge resize, drag-to-library removal, final reload absence, and 390px customize/library containment. DEF-20260623-DASH002-01 fixed invalid nested buttons in Block library live previews by rendering preview widget chrome in edit mode. -DASH-003 note: `dashboard.e2e.ts` verifies onboarding progress from clean E2E state (2/5: identity and first page complete, framework active, plugin/team not started), all five step labels/actions, the Settings modal action, workspace routes for New page/Browse plugins/Add members, 390px mobile containment, and server-backed dismiss persistence through `dashboard-layout`. +DASH-003 note: `dashboard.e2e.ts` runs in a dedicated post-setup, pre-persona project and verifies onboarding progress from genuinely clean E2E state (1/5: identity complete; framework in progress; first page, plugin, and team not started), all five step labels/actions, the Settings modal action, workspace routes for New page/Browse plugins/Add members, 390px mobile containment, and server-backed dismiss persistence through `dashboard-layout`. ## Capabilities And Access Control @@ -153,7 +153,7 @@ Visual builder note: `visual-builder.e2e.ts` inserts notch and picker modules, s SITE-005 note: full module picker coverage is automated in `visual-builder.e2e.ts`. The desktop regression opens Add to canvas, verifies Grid view, searches `button`, inserts Button with Enter, verifies the Button layer, reopens Recent to find the inserted item, switches to List view, closes/reopens, and verifies the persisted view preference. The drag regression filters for Text, drags the picker item into the center of an existing canvas Container, verifies the inside drop preview, edits the inserted Text, and verifies it renders inside the Container. The 390px mobile regression verifies the Add to canvas dialog, Search modules field, Modules/Recent category buttons, and filtered Button item stay viewport-contained without page-level horizontal overflow, then inserts Button with Enter and verifies the Layer row. Lower-level module-inserter suites cover corrupted localStorage fallback, recent dedupe, server-backed favorites, disabled/hidden module availability, and responsive category accessible names. -BUILDER-005 note: undo/redo history is automated in `visual-builder.e2e.ts`. The regression creates and reloads a disposable page to prove clean loaded history state, inserts Text, uses Ctrl/Cmd+Z and Ctrl/Cmd+Shift+Z to undo/redo the visible layer, uses the notch Undo button, inserts Container to prove Redo is cleared by a new edit, then saves/reloads and verifies the saved Container persists while transient history controls reset. +BUILDER-005 note: undo/redo history is automated in `visual-builder.e2e.ts`. The regression creates and reloads a disposable page to prove clean loaded history state, inserts Text, uses Ctrl/Cmd+Z and Ctrl/Cmd+Shift+Z to undo/redo the visible layer, uses the notch Undo button, inserts Container to prove Redo is cleared by a new edit, then saves/reloads and verifies the saved Container persists while transient history controls reset. Focused store regressions also prove undo/redo prunes selection ids for nodes removed by restored history, so the next insertion cannot silently target a stale node. BUILDER-007 note: breakpoint-scoped selector styles are automated in `visual-builder.e2e.ts`; the regression verifies desktop/mobile canvas frame separation and published CSS at default and 360px visitor widths. @@ -161,7 +161,7 @@ BUILDER-008 note: rich-body bold/italic persistence and public entry-template re SITE-017 note: `visual-builder.e2e.ts` componentizes a Text node, adds a Slot Outlet in VC mode, returns to the page, inserts Text into the locked generated slot, saves, publishes, and verifies an anonymous visitor sees both component body and slot fill while editor-only slot labels/component names stay absent. DEF-20260623-SITE017-001 fixed the page/component incremental-save ordering bug by writing component rows before page rows. -SITE-018 note: `visual-builder.e2e.ts` creates a high-priority Posts template from the Site panel, sets Template settings to Post types/Posts, inserts `{currentEntry.title}` through the binding picker, verifies synthetic canvas preview for title/body, saves and publishes the template snapshot, publishes a post, and verifies an anonymous `/posts/` visitor route renders the custom template with the published row data and no unresolved tokens. `content.e2e.ts` extends the binding coverage by adding a custom Posts field, inserting `{currentEntry.}` from the binding picker, verifying canvas preview resolution, publishing the template, and verifying the public post route resolves the custom value. +SITE-018 note: `visual-builder.e2e.ts` first publishes a disposable post, creates a higher-priority Posts template from the Site panel, explicitly selects that real row through Preview source, inserts `{currentEntry.title}` through the binding picker plus the content outlet, verifies the selected row title/body in canvas, saves and publishes the template snapshot, and verifies an anonymous `/posts/` visitor route renders the custom template with no unresolved tokens. `content.e2e.ts` extends the binding coverage by adding a custom Posts field, inserting `{currentEntry.}` from the binding picker, verifying canvas preview resolution, publishing the template, and verifying the public post route resolves the custom value. SITE-019 note: `visual-builder.e2e.ts` saves a styled Container subtree as a layout, verifies blank and duplicate-name validation, confirms the Layouts category remains reachable at 390px, inserts the saved layout into another page with its captured class styling, renames and deletes the saved layout from the inserter manage menu, saves/reloads, publishes, and verifies anonymous public output. DEF-20260623-SITE019-001 fixed stale node selection on `addPage`; DEF-20260623-SITE019-002 fixed count-only mobile category buttons; DEF-20260623-SITE019-003 fixed the saved-layout manage menu z-index under the spotlight inserter. @@ -253,7 +253,7 @@ MEDIA-007 note: unsafe SVG upload sanitization and public `/uploads` serving are CONTENT-003 note: slash-menu Heading 2 and Data token placeholder insertion with save/reload persistence are automated in `content.e2e.ts`; media picker insertion and sanitization edge cases remain lower-level or future browser coverage. -CONTENT-005 note: draft title/body rendering through the seeded entry template in Live mode is automated in `content.e2e.ts`; missing-template error handling, stale public-state comparison, and mobile live-canvas checks remain future browser coverage. +CONTENT-005 note: `content.e2e.ts` explicitly authors and publishes a low-priority Posts title/outlet template, then verifies unsaved draft title/body rendering through that owned fixture in Live mode. Missing-template error handling, stale public-state comparison, and mobile live-canvas checks remain future browser coverage. CONTENT-006 note: content built-in field toggles are automated in `content.e2e.ts`; custom field-schema edge cases and destructive collection deletion remain covered by lower-level tests or future browser expansion. @@ -269,7 +269,7 @@ CONTENT-008 note: `content.e2e.ts` adds a custom text field to the system Posts | AI-002 | P1 | partial | Defaults | Set default credential/model per AI scope | Credential with models | AI Defaults tab | A scope saves the intended credential/model pair, reloads with the saved selection resolved, and can clear the default so the credential can be deleted | no credentials, deleted credential, stale model list, all-scope coverage | | AI-003 | P2 | partial | Conversations | Continue saved AI conversations per scope | AI chat permission and configured default | AI assistant history | Saved Site chats can be listed, reloaded, and deleted from the history popover | cross-user access, deleted credential, title update, empty history | | AI-004 | P2 | partial | Chat | Stream scoped AI replies and tool-loop events | Configured provider/default | Site/content AI assistant | Prompt sends to a fixture-backed local provider, streamed text renders, and text-only usage persists for audit | provider SSE failure, aborts, empty prompt, large context, write-tool UI | -| AI-005 | P2 | partial | Tool Bridge | Return browser-executed tool results to the AI runtime | Active tool request | Browser bridge + `/ai/tool-result` | Browser `read_document` tool result correlates to the pending request and resumes the model loop | lost tab, timeout, duplicate result, malformed ids, write tools | +| AI-005 | P2 | partial | Tool Bridge | Return browser-executed tool results to the AI runtime | Active tool request | Browser bridge + `/ai/tool-result` | Browser `site_read_document` tool result correlates to the pending request and resumes the model loop | lost tab, timeout, duplicate result, malformed ids, write tools | | AI-006 | P2 | partial | Audit | Review AI usage rollups by user/scope/model/day | Usage generated by chat | AI Audit tab/dashboard widget | Audit tab renders model, scope, token, and daily rollups from a real streamed chat turn | empty usage, deleted labels, bad timezone, dashboard widget, mobile table | | AI-007 | P2 | partial | Drivers | Use provider REST drivers without SDK lock-in | Mocked or local provider | AI runtime/provider drivers | Direct drivers map messages/tools, stream events, usage, model catalogues, context windows, and prices without provider SDKs | malformed SSE, rate limits, unknown pricing | @@ -279,7 +279,7 @@ AI-003 note: Site assistant conversation creation, new-chat reset, history reloa AI-004 note: site assistant streaming against a fixture-backed local Ollama-compatible server is automated in `ai.e2e.ts`; write-tool/tool-loop UI, abort/error recovery, and content/data/plugin provider-backed permutations remain future coverage. -AI-005 note: fixture-backed local Ollama tool-loop coverage is automated in `ai.e2e.ts` for the browser-executed `read_document` path: tool request, browser result POST, provider second turn, completed tool badge, and final assistant text. CAP-005 also verifies request-level write-tool filtering for `ai.chat` without `ai.tools.write`. Mutating write-tool bridge success coverage, timeout/abort UX, duplicate/malformed result handling, permission-denied execution defense, and unknown-tool provider requests remain lower-level or future browser coverage. +AI-005 note: fixture-backed local Ollama tool-loop coverage is automated in `ai.e2e.ts` for the browser-executed `site_read_document` path: tool request, browser result POST, provider second turn, completed tool badge, and final assistant text. CAP-005 also verifies request-level write-tool filtering for `ai.chat` without `ai.tools.write`. Mutating write-tool bridge success coverage, timeout/abort UX, duplicate/malformed result handling, permission-denied execution defense, and unknown-tool provider requests remain lower-level or future browser coverage. AI-006 note: usage generated by the AI-004 site chat is verified in the Audit tab by model, surface, prompt/completion tokens, and daily spend in `ai.e2e.ts`; dashboard widget, range switching, timezone/user permission variants, deleted-label display, and mobile table layout remain future coverage. diff --git a/docs/e2e/feature-validation.tsv b/docs/e2e/feature-validation.tsv index 9834870b..50549ff9 100644 --- a/docs/e2e/feature-validation.tsv +++ b/docs/e2e/feature-validation.tsv @@ -10,12 +10,12 @@ PUB-002 Anonymous public homepage view As a site visitor, I want the published h PUB-003 Draft-public isolation after publish As a publisher, I want unpublished draft changes to stay private so visitors keep seeing the last published version until I publish again. After a page is published, editing the same homepage text and saving the draft without publishing updates the editor canvas/draft state but does not alter the anonymous public route, which still renders the last published text. Draft save accidentally bumps publish version; public render reads draft tables; dynamic cache uses stale or wrong version; visitor route caches draft-only content; publish status copy ambiguous after post-publish draft save. Draft save and publish are separate operations; public route reads active published snapshots/artifacts; public helper asserts published text visible and draft-only text absent after the later draft save. tests/e2e/core-owner-lifecycle.e2e.ts; tests/e2e/helpers/public.ts; server/publish/publishState.ts; server/publish/renderCache.ts; server/publish/publicRouter.ts; server/handlers/cms/site.ts The smoke uses one text node on the homepage; more complex data-row/template draft isolation is tracked by content/public rows. Happy: save post-publish draft and verify public still shows prior text. Error: draft-only text appears publicly. Boundary: same node edited after publish. Invalid: stale public cache invalidation mismatch. Permission: editor can save draft without publishing. Performance: public revisit stays prompt. Mobile: public mobile tracked separately. Focused core owner lifecycle Playwright regression passed 2026-06-23; canonical spreadsheet row added after matrix gap discovery 0 None Verification: `bun run test:e2e -- --project=e2e tests/e2e/core-owner-lifecycle.e2e.ts` passed 2/2 including setup. The scenario logs in from a clean owner context, signs out and back in, inserts homepage text, saves, reloads, publishes, verifies the anonymous public page, then saves a later unpublished draft and verifies the public route still shows the last published text. Run log: docs/e2e/runs/2026-06-23-core-owner-lifecycle.md. PUB-003 specifically verifies Automated E2E draft only headline appears in the editor after draft save but remains absent from the anonymous public route. 2026-06-23 AUTH-004 Active sessions and device management As an admin user, I want to see and revoke active devices so I can control account access. Account Active devices tab lists sessions; DELETE /auth/sessions/:id revokes one; POST /auth/logout-all revokes other sessions and keeps current session. Current session revoke through another tab causes redirect; logout-all excludes current session; unknown session id is handled; device labels may be unknown. Session routes require authenticated user; params are route-decoded; only self sessions are listed/revoked; revocation actions are step-up gated. server/handlers/cms/authSessions.ts; server/handlers/cms/auth.ts; src/admin/pages/account/tabs/SessionsTab.tsx; src/core/persistence/cmsAuth.ts Multiple browser contexts simulate other owner sessions; setup project's stored session may also appear and is safe to revoke in disposable E2E. Happy: list current and other sessions, sign out everywhere else through step-up, keep current session, and force the other context back to login on next request. Error: revoke unknown session shows recoverable error. Boundary: only one current session. Invalid: malformed id. Permission: cannot revoke another user's sessions. Performance: list loads with skeleton. Mobile: Active devices table stays contained in its own horizontal scroller at 390px and the page itself does not overflow sideways. Active-device list and sign-out-everywhere-else Playwright regression passed 2026-06-22; individual revoke, current-session rejection, cross-user guard, unknown-session error, and only-current logout-all server regressions passed through 2026-06-23; mobile active-devices table Playwright coverage passed 2026-06-23 0 None AUTH-004 promoted in `account.e2e.ts`: the test signs in from the primary page and a second browser context, opens Account -> Active devices, verifies the sessions table shows the current device and another sign-out action, runs Sign out everywhere else through the shared step-up prompt, verifies the current page remains logged in and the bulk action becomes disabled, then navigates the second context and verifies it returns to the Admin Login screen. The mobile AUTH-004 regression opens Active devices at 390x844 with another live session, verifies current/other-device rows remain reachable, verifies the table is contained inside its horizontal scroller, and verifies the document does not overflow sideways. Existing `authSessions.test.ts` covers session listing, hiding revoked sessions, individual secondary-device revoke, current-session revoke rejection, cross-user revoke guard, bulk revoke while preserving current session, auth-required listing, and device-label derivation. AUTH-004 edge regression 2026-06-23 adds unknown-session 404 envelope/no-mutation coverage and only-current `logout-all` no-op coverage. Verification: focused `bun run test:e2e -- --project=e2e tests/e2e/account.e2e.ts -g "active devices usable at mobile"` passed 2/2 including setup; full `bun run test:e2e -- --project=e2e tests/e2e/account.e2e.ts` passed 11/11 including setup; `bun test src/__tests__/server/authSessions.test.ts src/__tests__/server/authSessionEdgeCases.test.ts src/__tests__/server/authStepUp.test.ts` covers session API and step-up gates. Run logs: docs/e2e/runs/2026-06-22-auth004-active-devices.md; docs/e2e/runs/2026-06-23-auth004-session-edge-cases.md; docs/e2e/runs/2026-06-23-auth004-mobile-active-devices.md. 2026-06-23 AUTH-005 Step-up authorization As an authenticated admin, I want sensitive actions to require fresh password/MFA confirmation so risky operations are protected. Sensitive routes return step_up_required when the session lacks a fresh step-up window; StepUp dialog POSTs /auth/step-up, retries the original action, and stores stepUpExpiresAt. Cancel is silent/no mutation; wrong password shows dialog error; MFA-required users may need code; expired step-up forces re-prompt. Step-up body requires password and optional mfaCode; server policy has auth mode and window minutes; sensitive handlers call capability plus step-up gates. server/handlers/cms/auth.ts; src/admin/shared/StepUp; server/auth/authz.ts Publish, import, schema deletes, profile changes, plugin actions are representative sensitive flows. Happy: correct password retries action. Error: wrong password keeps dialog open. Boundary: prompt reappears after expiry. Invalid: empty password. Permission: lacking capability still blocked after step-up. Performance: retry does not double-submit. Mobile: dialog fields visible. Publish, user-creation, expired-window re-prompt, mobile/MFA user creation, plugin JSON-manifest install/uninstall, destructive CMS-bundle replace import, and secondary user-delete/role-delete/step-up-settings/session-revoke step-up coverage passed through 2026-06-23 0 None Manual run 2026-06-22 core-owner-lifecycle passed; publish step-up is covered by core-owner/publishing E2E. CAP-003 user-create Playwright regression verifies cancel leaves the user absent, wrong password keeps the step-up dialog open with an inline error and no mutation, and correct password retries the action and creates the user. CAP-003 expired-window regression creates a user through step-up, forces only the current disposable SQLite session row's step_up_expires_at into the past, verifies the next user-create attempt reopens step-up before mutation, then confirms correct password creates the second user. CAP-003 plugin install Playwright regression verifies permission review alone does not install, cancel leaves the plugin absent, wrong password leaves the plugin absent with inline step-up error, and correct password installs the manifest plugin. CAP-003 plugin uninstall Playwright regression verifies cancel and wrong password leave the plugin installed, and correct password removes it. CAP-003 destructive import Playwright regression verifies CMS-bundle replace import opens step-up, cancel and wrong password leave the local table intact, and correct password replaces local data. DEF-20260622-012 resolved: SiteImport CMS-bundle replace import surfaced `step_up_required` as a toast instead of opening the shared step-up dialog; useCmsBundleImport now wraps importSiteBundle in runStepUp and treats cancellation as non-error. DEF-20260622-013 resolved: successful login, MFA verify, and step-up now clear the per-IP limiter so repeated legitimate protected actions from one address do not block later logins. Reverified 2026-06-22 and normalized stale severity accounting: focused user-create step-up E2E passed 2/2 including setup, destructive site import step-up E2E passed 2/2 including setup, and the focused auth/site-import/data unit bundle passed 79/79. Secondary step-up API regression 2026-06-23 covers user delete no-mutation before step-up, capability denial after step-up, custom-role delete no-mutation before step-up, step-up policy `policy: always`, and individual secondary-session revoke no-mutation before step-up. Verification: `bun run test:e2e -- --project=e2e tests/e2e/users.e2e.ts -g "successful step-up"`, full `tests/e2e/users.e2e.ts`, `bun run test:e2e -- --project=e2e tests/e2e/users.e2e.ts -g "step-up window expires"`, `bun run test:e2e -- --project=e2e tests/e2e/capabilities.e2e.ts -g "plugin install requires successful step-up"`, `bun run test:e2e -- --project=e2e tests/e2e/capabilities.e2e.ts -g "plugin uninstall requires successful step-up"`, `bun run test:e2e -- --project=e2e tests/e2e/capabilities.e2e.ts -g "destructive site import requires successful step-up"`, full `tests/e2e/capabilities.e2e.ts`, `bun test src/__tests__/server/authStepUp.test.ts`, and `bun test src/__tests__/server/stepUpSecondaryActions.test.ts`. Run logs: docs/e2e/runs/2026-06-22-core-owner-lifecycle.md; docs/e2e/runs/2026-06-22-cap003-user-create-step-up.md; docs/e2e/runs/2026-06-22-cap003-plugin-install-step-up.md; docs/e2e/runs/2026-06-22-cap003-plugin-uninstall-step-up.md; docs/e2e/runs/2026-06-22-cap003-destructive-import-step-up.md; docs/e2e/runs/2026-06-22-auth-stepup-ip-limiter.md; docs/e2e/runs/2026-06-22-high-severity-defect-accounting.md; docs/e2e/runs/2026-06-23-auth005-secondary-step-up.md; docs/e2e/runs/2026-06-23-cap003-stale-step-up.md. 2026-06-23 -AUTH-006 Login lockout and sign-in activity As an admin user, I want failed sign-in attempts recorded and abusive attempts rate-limited/locked so suspicious activity is visible. Failed attempts are stored in login_attempts; lockout/rate-limit states affect login; Account activity tab lists recent successes/failures with suspicious banner for lock/rate-limited events. Unknown user attempts still log no_user; event timestamps parse to local display; failed count uses last 24 hours; rate limits should clear after window. Result enum includes success, bad_password, no_user, account_disabled, locked, rate_limited, mfa_failed; activity route returns current user's relevant rows. server/handlers/cms/auth.ts; src/admin/pages/account/tabs/ActivityTab.tsx; server/repositories/sessions.ts Tests should avoid locking shared owner unless disposable DB is reset. Happy: failed then successful login appears in history. Error: lockout blocks login with clear error. Boundary: 24h threshold. Invalid: malformed activity request. Permission: only own account history on Account tab. Performance: activity table loads quickly. Mobile: rows do not overflow. Activity-tab failed/successful login Playwright regression passed 2026-06-22; locked and rate-limited suspicious-banner component coverage passed 2026-06-23; disposable-account lockout/rate-limit browser regression passed 2026-06-23 0 None DEF-20260622-013 resolved: successful step-up attempts consumed the blanket per-IP login limiter but did not clear it, so legitimate bulk protected actions from one address could later block unrelated logins with "Too many login attempts from this address." Successful login, MFA verify, and step-up now reset the per-IP limiter. Verification: `bun test src/__tests__/server/authStepUp.test.ts` includes 35 successful IP-stamped step-ups, and full `bun run test:e2e -- --project=e2e tests/e2e/capabilities.e2e.ts` passed. AUTH-006 activity-tab promotion 2026-06-22: `account.e2e.ts` now submits a wrong password through the real login form, verifies the visible failure, signs in successfully, opens Account -> Sign-in history, and verifies the recent failed-count badge plus Success/Wrong password outcomes. Initial focused run exposed an automation-only strict-locator issue because multiple success rows are valid; the assertion now checks the first matching success/bad-password rows. Focused account E2E passed 5/5 including setup. AUTH-006 suspicious-banner promotion 2026-06-23: `accountPage.test.tsx` covers locked-event suspicious banner rendering and now verifies a recent `rate_limited` event also shows the suspicious banner, failed-count chip, `Rate-limited` outcome label, device label, and IP address. Verification: focused `bun test src/__tests__/admin/accountPage.test.tsx` passed 14/14; `bun run lint` passed; `bun run build` passed; full `bun test` passed 5585/5585. AUTH-006 lockout browser promotion 2026-06-23: `account.e2e.ts` now creates a disposable Admin account through the UI, logs that account in, submits five wrong passwords from a separate browser context to verify the lockout error, submits one more attempt to verify the tuple rate-limit error, and verifies the account's Activity tab shows the suspicious banner, recent failed-count chip, Success, Wrong password, and Rate-limited rows. Initial focused run failed due an automation-only role assumption: the first draft used the no-admin-access Member role, which correctly landed on "Access unavailable"; the corrected Admin-account flow passed. Verification: focused `bun run test:e2e -- --project=e2e tests/e2e/account.e2e.ts -g "shows lockout and rate-limit attempts as suspicious sign-in activity"` passed 2/2 including setup; full `bun run test:e2e -- --project=e2e tests/e2e/account.e2e.ts` passed 17/17. Run logs: docs/e2e/runs/2026-06-22-auth-stepup-ip-limiter.md; docs/e2e/runs/2026-06-22-auth006-activity-history.md; docs/e2e/runs/2026-06-23-auth006-rate-limited-activity.md; docs/e2e/runs/2026-06-23-auth006-lockout-browser.md. 2026-06-23 -ACCOUNT-001 Account profile update As an admin user, I want to edit my display name and email so my profile stays current. Account Profile tab shows current identity; PATCH /me updates displayName/email after step-up and refreshes session user; role information is read-only. Duplicate email or invalid email fails; cancellation leaves data unchanged; changing shared E2E email can break later tests. Profile body requires displayName max 160 and email length 3-320; server owns current user; step-up is required. server/handlers/cms/me.ts; src/admin/pages/account/tabs/ProfileTab.tsx; src/core/persistence/cmsAuth.ts E2E should prefer display-name changes unless it can rotate login credentials safely. Happy: display name saves and persists. Error: duplicate email. Boundary: max display name. Invalid: empty/invalid email. Permission: every authenticated user edits self only. Performance: save feedback clears. Mobile: form usable. Display-name update through step-up Playwright regression passed 2026-06-22; PATCH /me step-up denial, trimmed email save, max display-name boundary, invalid profile payloads, and duplicate-email rejection server regressions passed 2026-06-23; mobile Profile form and browser step-up cancel/no-op regressions passed 2026-06-23; browser email rotation remains API-covered future expansion 0 None ACCOUNT-001 recorded in `account.e2e.ts`: the test signs in from a fresh owner session, opens Account -> Profile, changes Name, saves through the shared step-up prompt, verifies "Profile saved.", reloads, and verifies the display name persisted in the Profile tab. ACCOUNT-001 API-edge promotion 2026-06-23: `accountSecurity.test.ts` verifies PATCH /me returns `{ error: 'step_up_required' }` before step-up without changing the requested email, accepts an exact 160-character display name and trims email before saving, recomputes a 64-character gravatarHash, rejects a duplicate normalized email with `{ error: 'Email is already in use' }`, rejects `not-an-email` with `{ error: 'Invalid email' }`, rejects overlong display names and missing email fields with `{ error: 'Invalid profile payload' }`, and verifies rejected requests leave the owner displayName/email unchanged. ACCOUNT-001 mobile/cancel promotion 2026-06-23: `account.e2e.ts` switches to a 390x844 viewport, opens Profile, captures the current display name/email, edits the display name, verifies Save is enabled and Profile controls are contained without horizontal overflow, opens the shared step-up prompt, cancels through `step-up-cancel`, verifies no profile status appears and the unsaved draft remains only in the form, reloads, and verifies the original display name/email still persisted with the mobile layout contained. Browser email editing uses the same form and endpoint but remains lower-level/API-covered because rotating the shared owner email can destabilize later login scenarios. Verification: focused `bun run test:e2e -- --project=e2e tests/e2e/account.e2e.ts -g "profile form usable at mobile"` passed 2/2 including setup; focused `bun run test:e2e -- --project=e2e tests/e2e/account.e2e.ts -g "ACCOUNT-001"` passed 3/3 including setup; full `bun run test:e2e -- --project=e2e tests/e2e/account.e2e.ts` passed 22/22; focused `bun test src/__tests__/server/accountSecurity.test.ts -t "PATCH /me"` passed 7/7; full `bun test src/__tests__/server/accountSecurity.test.ts` passed 16/16; prior focused `bun run test:e2e -- --project=e2e tests/e2e/account.e2e.ts -g "ACCOUNT-001|ACCOUNT-002"` passed 3/3 including setup; prior full `bun run test:e2e -- --project=e2e tests/e2e/account.e2e.ts` passed 9/9 including setup. Run logs: docs/e2e/runs/2026-06-22-account001-002-profile-avatar.md; docs/e2e/runs/2026-06-23-account001-profile-api-edges.md; docs/e2e/runs/2026-06-23-account001-mobile-cancel.md. 2026-06-23 +AUTH-006 Login lockout and sign-in activity As an admin user, I want failed sign-in attempts recorded and abusive attempts rate-limited/locked so suspicious activity is visible. Failed attempts are stored in login_attempts; lockout/rate-limit states affect login; Account activity tab lists recent successes/failures with suspicious banner for lock/rate-limited events. Unknown user attempts still log no_user; event timestamps parse to local display; failed count uses last 24 hours; rate limits should clear after window. Result enum includes success, bad_password, no_user, account_disabled, locked, rate_limited, mfa_failed; activity route returns current user's relevant rows. server/handlers/cms/auth.ts; src/admin/pages/account/tabs/ActivityTab.tsx; server/repositories/sessions.ts Routine activity checks use the dedicated account persona; lockout and rate-limit checks use disposable accounts so the shared owner is never locked. Happy: failed then successful login appears in history. Error: lockout blocks login with clear error. Boundary: 24h threshold. Invalid: malformed activity request. Permission: only own account history on Account tab. Performance: activity table loads quickly. Mobile: rows do not overflow. Activity-tab failed/successful login Playwright regression passed 2026-06-22; locked and rate-limited suspicious-banner component coverage passed 2026-06-23; disposable-account lockout/rate-limit browser regression passed 2026-06-23 0 None DEF-20260622-013 resolved: successful step-up attempts consumed the blanket per-IP login limiter but did not clear it, so legitimate bulk protected actions from one address could later block unrelated logins with "Too many login attempts from this address." Successful login, MFA verify, and step-up now reset the per-IP limiter. Verification: `bun test src/__tests__/server/authStepUp.test.ts` includes 35 successful IP-stamped step-ups, and full `bun run test:e2e -- --project=e2e tests/e2e/capabilities.e2e.ts` passed. AUTH-006 activity-tab promotion 2026-06-22: `account.e2e.ts` now submits a wrong password through the real login form, verifies the visible failure, signs in successfully, opens Account -> Sign-in history, and verifies the recent failed-count badge plus Success/Wrong password outcomes. Initial focused run exposed an automation-only strict-locator issue because multiple success rows are valid; the assertion now checks the first matching success/bad-password rows. Focused account E2E passed 5/5 including setup. AUTH-006 suspicious-banner promotion 2026-06-23: `accountPage.test.tsx` covers locked-event suspicious banner rendering and now verifies a recent `rate_limited` event also shows the suspicious banner, failed-count chip, `Rate-limited` outcome label, device label, and IP address. Verification: focused `bun test src/__tests__/admin/accountPage.test.tsx` passed 14/14; `bun run lint` passed; `bun run build` passed; full `bun test` passed 5585/5585. AUTH-006 lockout browser promotion 2026-06-23: `account.e2e.ts` now creates a disposable Admin account through the UI, logs that account in, submits five wrong passwords from a separate browser context to verify the lockout error, submits one more attempt to verify the tuple rate-limit error, and verifies the account's Activity tab shows the suspicious banner, recent failed-count chip, Success, Wrong password, and Rate-limited rows. Initial focused run failed due an automation-only role assumption: the first draft used the no-admin-access Member role, which correctly landed on "Access unavailable"; the corrected Admin-account flow passed. Verification: focused `bun run test:e2e -- --project=e2e tests/e2e/account.e2e.ts -g "shows lockout and rate-limit attempts as suspicious sign-in activity"` passed 2/2 including setup; full `bun run test:e2e -- --project=e2e tests/e2e/account.e2e.ts` passed 17/17. Run logs: docs/e2e/runs/2026-06-22-auth-stepup-ip-limiter.md; docs/e2e/runs/2026-06-22-auth006-activity-history.md; docs/e2e/runs/2026-06-23-auth006-rate-limited-activity.md; docs/e2e/runs/2026-06-23-auth006-lockout-browser.md. 2026-06-23 +ACCOUNT-001 Account profile update As an admin user, I want to edit my display name and email so my profile stays current. Account Profile tab shows current identity; PATCH /me updates displayName/email after step-up and refreshes session user; role information is read-only. Duplicate email or invalid email fails; cancellation leaves data unchanged; changing the reusable account-persona email can break later tests. Profile body requires displayName max 160 and email length 3-320; server owns current user; step-up is required. server/handlers/cms/me.ts; src/admin/pages/account/tabs/ProfileTab.tsx; src/core/persistence/cmsAuth.ts E2E should prefer display-name changes unless it can rotate login credentials safely. Happy: display name saves and persists. Error: duplicate email. Boundary: max display name. Invalid: empty/invalid email. Permission: every authenticated user edits self only. Performance: save feedback clears. Mobile: form usable. Display-name update through step-up Playwright regression passed 2026-06-22; PATCH /me step-up denial, trimmed email save, max display-name boundary, invalid profile payloads, and duplicate-email rejection server regressions passed 2026-06-23; mobile Profile form and browser step-up cancel/no-op regressions passed 2026-06-23; browser email rotation remains API-covered future expansion 0 None ACCOUNT-001 recorded in `account.e2e.ts`: the test signs in as the dedicated account persona, opens Account -> Profile, changes Name, saves through the step-up prompt, verifies "Profile saved.", reloads, and verifies the display name persisted in the Profile tab. ACCOUNT-001 API-edge promotion 2026-06-23: `accountSecurity.test.ts` verifies PATCH /me returns `{ error: 'step_up_required' }` before step-up without changing the requested email, accepts an exact 160-character display name and trims email before saving, recomputes a 64-character gravatarHash, rejects a duplicate normalized email with `{ error: 'Email is already in use' }`, rejects `not-an-email` with `{ error: 'Invalid email' }`, rejects overlong display names and missing email fields with `{ error: 'Invalid profile payload' }`, and verifies rejected requests leave the owner displayName/email unchanged. ACCOUNT-001 mobile/cancel promotion 2026-06-23: `account.e2e.ts` switches to a 390x844 viewport, opens Profile, captures the current display name/email, edits the display name, verifies Save is enabled and Profile controls are contained without horizontal overflow, opens the step-up prompt, cancels through `step-up-cancel`, verifies no profile status appears and the unsaved draft remains only in the form, reloads, and verifies the original display name/email still persisted with the mobile layout contained. Browser email editing uses the same form and endpoint but remains lower-level/API-covered because rotating the reusable account-persona email can destabilize later login scenarios. Verification: focused `bun run test:e2e -- --project=e2e tests/e2e/account.e2e.ts -g "profile form usable at mobile"` passed 2/2 including setup; focused `bun run test:e2e -- --project=e2e tests/e2e/account.e2e.ts -g "ACCOUNT-001"` passed 3/3 including setup; full `bun run test:e2e -- --project=e2e tests/e2e/account.e2e.ts` passed 22/22; focused `bun test src/__tests__/server/accountSecurity.test.ts -t "PATCH /me"` passed 7/7; full `bun test src/__tests__/server/accountSecurity.test.ts` passed 16/16; prior focused `bun run test:e2e -- --project=e2e tests/e2e/account.e2e.ts -g "ACCOUNT-001|ACCOUNT-002"` passed 3/3 including setup; prior full `bun run test:e2e -- --project=e2e tests/e2e/account.e2e.ts` passed 9/9 including setup. Run logs: docs/e2e/runs/2026-06-22-account001-002-profile-avatar.md; docs/e2e/runs/2026-06-23-account001-profile-api-edges.md; docs/e2e/runs/2026-06-23-account001-mobile-cancel.md. 2026-06-23 ACCOUNT-002 Avatar upload and removal As an admin user, I want to upload or clear my avatar so account menus show the intended image. POST /me/avatar accepts an image upload, creates/associates media, returns avatar URL; DELETE /me/avatar clears avatar and falls back to generated/gravatar display. Unsupported MIME fails; large upload should fail cleanly; removing missing avatar is idempotent; media storage errors surface. Avatar route accepts FormData; upload validation and media storage rules apply; self-targeted authenticated route. server/handlers/cms/me.ts; server/handlers/cms/mediaUpload.ts; src/admin/pages/account/tabs/ProfileTab.tsx Requires local upload directory or configured media adapter. Happy: upload image changes avatar. Error: unsupported file shows validation. Boundary: small valid PNG. Invalid: empty form. Permission: user can only update self. Performance: upload progress/feedback. Mobile: upload control reachable. Small PNG avatar upload/persistence, removal, unsupported-MIME inline-error, oversized-upload inline-error, empty multipart upload rejection, idempotent missing-avatar removal, mobile upload/remove layout, and elected storage-adapter failure regressions passed 2026-06-23 0 None ACCOUNT-002 recorded in `account.e2e.ts`: the first test opens Account -> Profile, uploads a minimal valid PNG through the hidden file input behind the Upload picture control, verifies "Profile picture updated.", reloads, and verifies the Remove action is available, proving the uploaded avatar remains associated with the current user. ACCOUNT-002 removal promotion 2026-06-23: the second test uploads its own minimal PNG, clicks Remove, verifies "Profile picture removed.", verifies the Remove action disappears and Upload picture returns, reloads, and verifies the removed state persists. ACCOUNT-002 unsupported-MIME promotion 2026-06-23: the third test opens Profile, clears any existing avatar, uploads a text/plain payload through the avatar input, verifies the inline alert "Avatars must be a JPEG, PNG, GIF, or WebP image", and verifies the account remains in the upload-only state. ACCOUNT-002 oversized-upload promotion 2026-06-23: the fourth test opens Profile, clears any existing avatar, uploads a 5 MiB + 1 byte avatar payload, verifies the inline alert "Avatar must be smaller than 5 MB", and verifies the account remains in the upload-only state. Handled upload failures log the existing `[profile-tab] avatar upload failed:` console error by design. ACCOUNT-002 API-edge promotion 2026-06-23: `accountSecurity.test.ts` verifies POST /me/avatar with an empty multipart body returns `{ error: 'Missing file' }` without changing avatarMediaId, and DELETE /me/avatar on a user with no uploaded avatar returns a normal user payload with avatarMediaId/avatarUrl null and gravatarHash populated. ACCOUNT-002 mobile-layout promotion 2026-06-23: `account.e2e.ts` switches to a 390x844 viewport, opens Profile, verifies the Upload picture action and size/MIME hint are visible and horizontally contained with no page overflow, uploads a valid PNG, verifies Change picture/Remove/status remain contained, removes the avatar, and verifies the upload-only state remains contained. ACCOUNT-002 storage-error promotion 2026-06-23: `accountSecurity.test.ts` configures a temporary local-disk registry, elects a missing avatar storage adapter, uploads a valid PNG through POST /me/avatar, verifies the 503 `{ error: 'Elected media storage adapter "missing.avatar" is not currently available for role "avatar". The plugin that provides it may be disabled.' }` envelope, verifies avatarMediaId stays null, and verifies no media_assets row is created. Verification: focused `bun run test:e2e -- --project=e2e tests/e2e/account.e2e.ts -g "profile picture controls usable at mobile"` passed 2/2 including setup; focused `bun run test:e2e -- --project=e2e tests/e2e/account.e2e.ts -g "ACCOUNT-002"` passed 6/6 including setup; full `bun run test:e2e -- --project=e2e tests/e2e/account.e2e.ts` passed 21/21; focused `bun test src/__tests__/server/accountSecurity.test.ts -t "avatar"` passed 3/3; full `bun test src/__tests__/server/accountSecurity.test.ts` passed 13/13; prior focused `bun run test:e2e -- --project=e2e tests/e2e/account.e2e.ts -g "ACCOUNT-001|ACCOUNT-002"` passed 3/3 including setup. Run logs: docs/e2e/runs/2026-06-22-account001-002-profile-avatar.md; docs/e2e/runs/2026-06-23-account002-avatar-removal.md; docs/e2e/runs/2026-06-23-account002-invalid-avatar.md; docs/e2e/runs/2026-06-23-account002-oversized-avatar.md; docs/e2e/runs/2026-06-23-account002-avatar-api-edges.md; docs/e2e/runs/2026-06-23-account002-mobile-avatar.md; docs/e2e/runs/2026-06-23-account002-storage-error.md. 2026-06-23 -ACCOUNT-003 Password change As an admin user, I want to change my password so old credentials stop working. Security tab calls PATCH /me/password through step-up; server hashes new password, rotates session state as implemented, and returns updated user/password timestamp. Too-short password rejected; mismatched confirmation is local-only; cancel/no step-up leaves old password; existing sessions are revoked by server policy; autofill should not corrupt fields. New password minLength 12; confirmation must match before submit; step-up gate; current user required. server/handlers/cms/me.ts; src/admin/pages/account/tabs/PasswordSettingsCard.tsx; src/core/persistence/cmsAuth.ts Browser E2E must use disposable credentials and restore the shared owner password before the suite continues. Happy: change password through step-up, reject old password, log in with new password, restore original password. Error: too-short password and mismatched confirmation show field-local alerts. Boundary: exactly 12 characters reaches mismatch validation instead of length validation. Invalid: empty field. Permission: self only. Performance: save state visible. Mobile: Password card action and Change password dialog stay contained at 390px, and fields, footer actions, and validation alerts remain reachable without page-level horizontal overflow. Password-change validation and rotation Playwright regression passed 2026-06-22; mobile password card/dialog layout and exact-minimum local validation Playwright coverage passed 2026-06-23; multi-session revocation browser proof and autofill variants remain future expansion 0 None ACCOUNT-003 promoted in `account.e2e.ts`: the rotation test opens Account Security, verifies too-short password and mismatched confirmation alerts in the Change password dialog, changes the owner password through the shared step-up prompt, verifies the success status, signs out, confirms the old password is rejected, signs in with the temporary password, restores the original shared E2E password through a second step-up, and signs in again with the original password. The mobile ACCOUNT-003 regression opens Account -> Security at 390x844, verifies the Password card action stays contained, opens Change password, verifies fields and footer actions are visible, verifies too-short validation, verifies an exact-12-character mismatch reaches the mismatch alert instead of the length alert, verifies dialog controls and alerts are contained within the viewport, and cancels without changing credentials. Initial focused run exposed an automation-only strict-locator issue because the Security tab renders multiple valid `role="status"` messages; the assertion now targets the specific password-updated copy. Verification: focused `bun run test:e2e -- --project=e2e tests/e2e/account.e2e.ts -g "password change usable at mobile"` passed 2/2 including setup; previous focused `bun run test:e2e -- --project=e2e tests/e2e/account.e2e.ts -g "ACCOUNT-003"` passed 2/2 including setup; full `bun run test:e2e -- --project=e2e tests/e2e/account.e2e.ts` passed 12/12 including setup. Run logs: docs/e2e/runs/2026-06-22-account003-password-change.md; docs/e2e/runs/2026-06-23-account003-mobile-password.md. 2026-06-23 +ACCOUNT-003 Password change As an admin user, I want to change my password so old credentials stop working. Security tab calls PATCH /me/password through step-up; server hashes new password, rotates session state as implemented, and returns updated user/password timestamp. Too-short password rejected; mismatched confirmation is local-only; cancel/no step-up leaves old password; existing sessions are revoked by server policy; autofill should not corrupt fields. New password minLength 12; confirmation must match before submit; step-up gate; current user required. server/handlers/cms/me.ts; src/admin/pages/account/tabs/PasswordSettingsCard.tsx; src/core/persistence/cmsAuth.ts Browser E2E uses the dedicated account persona and restores its baseline password before the suite continues. Happy: change password through step-up, reject old password, log in with new password, restore original password. Error: too-short password and mismatched confirmation show field-local alerts. Boundary: exactly 12 characters reaches mismatch validation instead of length validation. Invalid: empty field. Permission: self only. Performance: save state visible. Mobile: Password card action and Change password dialog stay contained at 390px, and fields, footer actions, and validation alerts remain reachable without page-level horizontal overflow. Password-change validation and rotation Playwright regression passed 2026-06-22; mobile password card/dialog layout and exact-minimum local validation Playwright coverage passed 2026-06-23; multi-session revocation browser proof and autofill variants remain future expansion 0 None ACCOUNT-003 promoted in `account.e2e.ts`: the rotation test opens Account Security, verifies too-short password and mismatched confirmation alerts in the Change password dialog, changes the account-persona password through the step-up prompt, verifies the success status, signs out, confirms the old password is rejected, signs in with the temporary password, restores the baseline account-persona password through a second step-up, and signs in again with the baseline password. The mobile ACCOUNT-003 regression opens Account -> Security at 390x844, verifies the Password card action stays contained, opens Change password, verifies fields and footer actions are visible, verifies too-short validation, verifies an exact-12-character mismatch reaches the mismatch alert instead of the length alert, verifies dialog controls and alerts are contained within the viewport, and cancels without changing credentials. Initial focused run exposed an automation-only strict-locator issue because the Security tab renders multiple valid `role="status"` messages; the assertion now targets the specific password-updated copy. Verification: focused `bun run test:e2e -- --project=e2e tests/e2e/account.e2e.ts -g "password change usable at mobile"` passed 2/2 including setup; previous focused `bun run test:e2e -- --project=e2e tests/e2e/account.e2e.ts -g "ACCOUNT-003"` passed 2/2 including setup; full `bun run test:e2e -- --project=e2e tests/e2e/account.e2e.ts` passed 12/12 including setup. Run logs: docs/e2e/runs/2026-06-22-account003-password-change.md; docs/e2e/runs/2026-06-23-account003-mobile-password.md. 2026-06-23 ACCOUNT-004 TOTP MFA and recovery codes As an admin user, I want to enable/disable MFA and regenerate recovery codes so account security can be managed. Security tab starts TOTP setup, shows secret/QR, enables with code, stores recovery codes, can disable TOTP, and regenerate codes through step-up-gated endpoints. Invalid TOTP code fails; QR rendering failure falls back to secret; recovery-code dialog must be dismissible; canceling setup must not enable MFA. TOTP start returns secret and otpauthUrl; enable body requires secret min 16 and code min 6; recovery codes returned once; step-up for sensitive operations. server/handlers/cms/me.ts; src/admin/pages/account/tabs/MfaSettingsCards.tsx Browser E2E generates TOTP codes from the displayed setup key; invalid setup-code feedback, empty/wrong MFA login-code feedback, mobile MFA login-challenge containment, recovery-code login burn, and reuse rejection are covered by account E2E, and QR-failure fallback is covered by focused AccountPage regression. Happy: start MFA setup through step-up, verify QR/manual key/authentication-code UI, cancel, reload, confirm MFA remains off, enable MFA with a generated TOTP code, verify recovery codes are shown, sign out/in through MFA, regenerate recovery codes through MFA step-up, disable MFA, and confirm recovery-code generation is disabled. Error: wrong setup code shows the Invalid authentication code inline alert, keeps the setup dialog open, re-enables submit, and leaves MFA Off after cancel/reload; empty MFA login code focuses the required field and exposes native validation before any request; wrong MFA login code shows the same inline alert and leaves the MFA challenge visible until a valid TOTP is submitted; QR rendering failure shows QR code unavailable, keeps the manual setup key and authenticator link visible, and still allows MFA enablement. Boundary: recovery code login consumes one code and rejects reuse. Invalid: malformed secret. Permission: self only. Performance: QR generation failure does not block manual setup. Mobile: setup dialog opens after step-up at 390px with QR/manual key/copy link/code/footer actions contained, the code input is scroll-reachable, entering six digits enables the submit action, and cancel keeps MFA off; MFA login challenge at 390x844 stays viewport-contained with the code input, Verify action, and wrong-code feedback contained before valid TOTP completes login. MFA setup/cancel, invalid setup-code feedback, empty/wrong MFA login-code feedback, QR-failure manual-key fallback, full enable/TOTP-login/recovery-regenerate/disable, recovery-code login burn/reuse rejection, mobile setup-dialog containment, and mobile MFA login-challenge regressions passed through 2026-06-23 0 None ADMIN-003 promoted in `account.e2e.ts`: the test opens Account Security, verifies MFA is Off, starts setup through the step-up prompt, verifies the setup dialog with QR/manual key/authentication-code field and disabled Enable MFA button, cancels, then reloads and verifies MFA remains Off and recovery-code generation remains disabled. ACCOUNT-004 promoted in `account.e2e.ts`: the test enables MFA with a generated code from the displayed manual setup key, verifies the recovery-code dialog shows 10 codes, signs out, clicks Verify with the MFA login field empty and verifies browser required-field validation, submits a guaranteed-wrong MFA login code and verifies the inline `Invalid authentication code` alert without leaving the challenge, completes MFA login with a generated code, regenerates recovery codes through the shared step-up dialog requiring password plus MFA code, disables MFA, and verifies recovery-code generation returns to disabled. AUTH-002 promotion adds recovery-code login burn/reuse coverage: one generated recovery code completes the Two-Factor Authentication screen and Account Security then shows 9 recovery codes remaining; a second login attempt with the same recovery code stays on the MFA screen with inline `Invalid authentication code`, then a generated TOTP code completes the pending challenge so MFA can be disabled cleanly. The invalid-code ACCOUNT-004 regression starts setup through step-up, derives a guaranteed-wrong six-digit code from the displayed setup key, submits it, verifies the inline `Invalid authentication code` alert, verifies the setup dialog remains open and submit is re-enabled, then cancels/reloads and verifies MFA remains Off with recovery-code generation disabled. The mobile ACCOUNT-004 regression opens Account -> Security at 390x844, starts MFA setup through step-up, waits for the QR/manual key/copy/link/code controls, verifies the dialog stays viewport-contained without page-level sideways overflow, verifies the authentication-code input can be scrolled into view, fills a six-digit code to enable the submit action, then cancels and verifies MFA remains Off and recovery-code regeneration remains disabled. The mobile AUTH-002/ACCOUNT-004 login regression enables MFA, signs out, switches to a 390x844 viewport, verifies the MFA login challenge is contained without horizontal overflow, verifies wrong-code feedback remains visible and contained, then completes login and disables MFA cleanly. The QR-failure fallback regression in `accountPage.test.tsx` simulates QR data-URL generation failure, verifies `QR code unavailable` plus the inline fallback message, verifies the manual setup key and authenticator link remain visible, then enters a valid-length code and confirms recovery codes are shown. Initial focused ACCOUNT-004 run exposed an automation-only expectation mismatch: disable reused the fresh step-up window from recovery-code regeneration and completed without opening a second dialog, which matches the implemented step-up-window policy. Verification: focused `bun run test:e2e -- --project=e2e tests/e2e/account.e2e.ts -g "enables MFA"` passed 2/2 including setup; focused `bun run test:e2e -- --project=e2e tests/e2e/account.e2e.ts -g "recovery code"` passed 2/2 including setup; focused `bun run test:e2e -- --project=e2e tests/e2e/account.e2e.ts -g "invalid MFA setup code"` passed 2/2 including setup; focused `bun run test:e2e -- --project=e2e tests/e2e/account.e2e.ts -g "MFA setup dialog usable"` passed 2/2 including setup; previous focused `bun run test:e2e -- --project=e2e tests/e2e/account.e2e.ts -g "ACCOUNT-004"` passed 2/2 including setup; focused `bun run test:e2e -- --project=e2e tests/e2e/account.e2e.ts -g "MFA login challenge"` passed 2/2 including setup; focused `bun test src/__tests__/admin/accountPage.test.tsx` passed 13/13; full `bun run test:e2e -- --project=e2e tests/e2e/account.e2e.ts` passed 16/16 including setup after the mobile MFA challenge assertion. Run logs: docs/e2e/runs/2026-06-22-admin003-mfa-setup-cancel.md; docs/e2e/runs/2026-06-22-account004-mfa-lifecycle.md; docs/e2e/runs/2026-06-22-auth002-recovery-code-login.md; docs/e2e/runs/2026-06-23-account004-mobile-mfa-setup.md; docs/e2e/runs/2026-06-23-account004-invalid-mfa-code.md; docs/e2e/runs/2026-06-23-auth002-recovery-code-reuse.md; docs/e2e/runs/2026-06-23-auth002-wrong-mfa-code.md; docs/e2e/runs/2026-06-23-auth002-empty-mfa-code.md; docs/e2e/runs/2026-06-23-auth002-mobile-mfa-login.md; docs/e2e/runs/2026-06-23-account004-qr-fallback.md. 2026-06-23 -ACCOUNT-005 Step-up policy preferences As an admin user, I want to configure my step-up prompt behaviour so sensitive actions match my security preference. Security card updates mode and window minutes via PATCH /me/security/step-up and returns the updated current user; the route always requires a fresh step-up even when normal sensitive gates are disabled. Unsupported mode/minutes rejected; disabled mode bypasses normal user-policy gates but not the policy editor itself; setting a short window should re-prompt soon; cancel leaves current policy. mode union is required or disabled; windowMinutes union is 5, 15, 30, or 60; self-targeted route; update is authenticated and step-up-gated with policy always. server/handlers/cms/me.ts; server/auth/authz.ts; server/auth/stepUpPolicy.ts; src/admin/pages/account/tabs/StepUpSettingsCard.tsx; src/core/persistence/cmsAuth.ts Browser E2E restores the owner window to the default 15 minutes so later account/security scenarios keep the expected shared state. Happy: change policy window to 30 minutes through step-up, verify success status and card copy, reload, verify persistence, restore to 15 minutes. Error: API failure shown. Boundary: 5 and 60 minute options are reachable in the window menu, and the server computes the configured expiry timestamp when step-up is reopened. Invalid: unsupported mode/window values reject without changing the stored policy. Permission: self only and policy changes still require step-up when normal gates are disabled. Performance: immediate feedback. Mobile: Step-up authentication controls stack and stay contained at 390px, the Step-up window dropdown stays inside the viewport, and selecting the current value closes without triggering step-up. Step-up window change and reload persistence Playwright regression passed 2026-06-22; mobile step-up control/dropdown layout Playwright coverage passed 2026-06-23; disabled-mode bypass, invalid option rejection, and configured expiry timestamp server coverage passed 2026-06-23; browser API failure display remains future expansion; stale-window re-prompt is covered by CAP-003 0 None ACCOUNT-005 promoted in `account.e2e.ts`: the persistence test signs in fresh, opens Account -> Security, verifies the default required 15-minute step-up state, changes the Step-up window combobox to 30 minutes, completes the shared step-up prompt, verifies the success message and security-card status, reloads and verifies the 30-minute value persists, then restores the window to 15 minutes and verifies the restoration feedback. The mobile ACCOUNT-005 regression opens Account -> Security at 390x844, verifies the required 15-minute policy state, verifies the Step-up authentication controls are stacked and contained, opens the Step-up window listbox, verifies the 5-minute and 60-minute options are reachable, verifies the dropdown stays inside the viewport without page-level sideways overflow, selects the already-current 15-minute option, and verifies no step-up dialog opens because no mutation was requested. Server ACCOUNT-005 coverage in `accountSecurity.test.ts` verifies the policy route requires step-up, disabled mode bypasses normal sensitive gates without bypassing the policy editor, unsupported `mode`/`windowMinutes` values return `Invalid step-up settings` after step-up, invalid payloads leave the stored `required`/15-minute policy unchanged, and a configured 30-minute policy drives the next step-up expiry timestamp. `authStepUp.test.ts` also verifies sensitive actions reject once a fresh step-up window expires; CAP-003 browser coverage now verifies the UI re-prompts before mutation after the current session's `step_up_expires_at` is expired. Initial focused run exposed a test-only interaction bug: the project Select primitive renders a combobox/listbox, not a native select, so the persistence test now clicks the combobox and chooses visible options by role. Mobile focused debugging 2026-06-23 exposed two test-only assertion issues: the first helper compared switch/select left edges too strictly despite screenshot evidence of a stacked contained layout, and Playwright fuzzy matching let `5 minutes` match `15 minutes`; the helper now asserts vertical stacking/containment and option locators use exact accessible names. Verification: focused `bun run test:e2e -- --project=e2e tests/e2e/account.e2e.ts -g "step-up policy controls usable"` passed 2/2 including setup; previous focused `bun run test:e2e -- --project=e2e tests/e2e/account.e2e.ts -g "ACCOUNT-005"` passed 2/2 including setup; focused `bun test src/__tests__/server/accountSecurity.test.ts` passed 10/10; focused `bun test src/__tests__/server/authStepUp.test.ts` passed 17/17; `bun run lint` passed; `bun run build` passed; full `bun test` passed 5584/5584; full `bun run test:e2e -- --project=e2e tests/e2e/account.e2e.ts` passed 13/13 including setup in the earlier browser promotion. Run logs: docs/e2e/runs/2026-06-22-account005-step-up-policy.md; docs/e2e/runs/2026-06-23-account005-mobile-step-up-policy.md; docs/e2e/runs/2026-06-23-account005-invalid-step-up-policy.md; docs/e2e/runs/2026-06-23-cap003-stale-step-up.md. 2026-06-23 +ACCOUNT-005 Step-up policy preferences As an admin user, I want to configure my step-up prompt behaviour so sensitive actions match my security preference. Security card updates mode and window minutes via PATCH /me/security/step-up and returns the updated current user; the route always requires a fresh step-up even when normal sensitive gates are disabled. Unsupported mode/minutes rejected; disabled mode bypasses normal user-policy gates but not the policy editor itself; setting a short window should re-prompt soon; cancel leaves current policy. mode union is required or disabled; windowMinutes union is 5, 15, 30, or 60; self-targeted route; update is authenticated and step-up-gated with policy always. server/handlers/cms/me.ts; server/auth/authz.ts; server/auth/stepUpPolicy.ts; src/admin/pages/account/tabs/StepUpSettingsCard.tsx; src/core/persistence/cmsAuth.ts Browser E2E restores the account persona window to the default 15 minutes so later account/security scenarios keep the expected persona state. Happy: change policy window to 30 minutes through step-up, verify success status and card copy, reload, verify persistence, restore to 15 minutes. Error: API failure shown. Boundary: 5 and 60 minute options are reachable in the window menu, and the server computes the configured expiry timestamp when step-up is reopened. Invalid: unsupported mode/window values reject without changing the stored policy. Permission: self only and policy changes still require step-up when normal gates are disabled. Performance: immediate feedback. Mobile: Step-up authentication controls stack and stay contained at 390px, the Step-up window dropdown stays inside the viewport, and selecting the current value closes without triggering step-up. Step-up window change and reload persistence Playwright regression passed 2026-06-22; mobile step-up control/dropdown layout Playwright coverage passed 2026-06-23; disabled-mode bypass, invalid option rejection, and configured expiry timestamp server coverage passed 2026-06-23; browser API failure display remains future expansion; stale-window re-prompt is covered by CAP-003 0 None ACCOUNT-005 promoted in `account.e2e.ts`: the persistence test signs in as the dedicated account persona, opens Account -> Security, verifies the default required 15-minute step-up state, changes the Step-up window combobox to 30 minutes, completes the step-up prompt, verifies the success message and security-card status, reloads and verifies the 30-minute value persists, then restores the window to 15 minutes and verifies the restoration feedback. The mobile ACCOUNT-005 regression opens Account -> Security at 390x844, verifies the required 15-minute policy state, verifies the Step-up authentication controls are stacked and contained, opens the Step-up window listbox, verifies the 5-minute and 60-minute options are reachable, verifies the dropdown stays inside the viewport without page-level sideways overflow, selects the already-current 15-minute option, and verifies no step-up dialog opens because no mutation was requested. Server ACCOUNT-005 coverage in `accountSecurity.test.ts` verifies the policy route requires step-up, disabled mode bypasses normal sensitive gates without bypassing the policy editor, unsupported `mode`/`windowMinutes` values return `Invalid step-up settings` after step-up, invalid payloads leave the stored `required`/15-minute policy unchanged, and a configured 30-minute policy drives the next step-up expiry timestamp. `authStepUp.test.ts` also verifies sensitive actions reject once a fresh step-up window expires; CAP-003 browser coverage now verifies the UI re-prompts before mutation after the current session's `step_up_expires_at` is expired. Initial focused run exposed a test-only interaction bug: the project Select primitive renders a combobox/listbox, not a native select, so the persistence test now clicks the combobox and chooses visible options by role. Mobile focused debugging 2026-06-23 exposed two test-only assertion issues: the first helper compared switch/select left edges too strictly despite screenshot evidence of a stacked contained layout, and Playwright fuzzy matching let `5 minutes` match `15 minutes`; the helper now asserts vertical stacking/containment and option locators use exact accessible names. Verification: focused `bun run test:e2e -- --project=e2e tests/e2e/account.e2e.ts -g "step-up policy controls usable"` passed 2/2 including setup; previous focused `bun run test:e2e -- --project=e2e tests/e2e/account.e2e.ts -g "ACCOUNT-005"` passed 2/2 including setup; focused `bun test src/__tests__/server/accountSecurity.test.ts` passed 10/10; focused `bun test src/__tests__/server/authStepUp.test.ts` passed 17/17; `bun run lint` passed; `bun run build` passed; full `bun test` passed 5584/5584; full `bun run test:e2e -- --project=e2e tests/e2e/account.e2e.ts` passed 13/13 including setup in the earlier browser promotion. Run logs: docs/e2e/runs/2026-06-22-account005-step-up-policy.md; docs/e2e/runs/2026-06-23-account005-mobile-step-up-policy.md; docs/e2e/runs/2026-06-23-account005-invalid-step-up-policy.md; docs/e2e/runs/2026-06-23-cap003-stale-step-up.md. 2026-06-23 ADMIN-001 Admin boot, setup/login/editor routing As a visitor to /admin, I want the app to choose setup, login, or authenticated admin state correctly. Static admin shell embeds boot probes for setup status, me, and public site; unauthenticated users see login/setup; authenticated users load lazy workspace shell. Expired cookie; public-site fetch failure; direct /admin/unknown routes; Vite dev asset failures; fallback accessible message. Boot response schemas validated by client; session cookie scoped to /admin; internal router owns admin navigation. server/static.ts; src/admin/AdminEntry.tsx; src/admin/AuthenticatedAdmin.tsx; src/admin/lib/routing Tests can start from direct URLs with clean/stale cookies. Happy: /admin loads correct phase. Error: expired session returns login. Boundary: direct /admin/dashboard. Invalid: unknown route fallback. Permission: disallowed workspace redirects/denies. Performance: no long blank screen. Mobile: skeleton/login fit. Manual core lifecycle passed 2026-06-22; automated/build/lint/bundle/Playwright E2E passed; broader exploratory pending 0 None Manual run 2026-06-22 core-owner-lifecycle passed; see docs/e2e/runs/2026-06-22-core-owner-lifecycle.md. 2026-06-22 ADMIN-002 Workspace navigation and access gates As an admin user, I want navigation to show only permitted workspaces and direct URLs to respect permissions. AuthenticatedAdmin maps path prefixes to workspace pages, checks canAccessWorkspace, redirects to first accessible workspace or shows access unavailable. Role with no dashboard; plugin page routes; account accessible to every authenticated user; stale nav state after role change. Capabilities in src/admin/access.ts drive client gates; server route capabilities must also deny direct API access. src/admin/AuthenticatedAdmin.tsx; src/admin/access.ts; src/admin/pages/site/toolbar/Toolbar.tsx; server/auth/authz.ts Capability tests may need custom roles and separate sessions. Happy: full owner navigates all workspaces. Error: direct disallowed URL denied. Boundary: user with one workspace. Invalid: unknown workspace path. Permission: nav hides inaccessible links. Performance: prewarmed pages avoid flash. Mobile: nav usable or collapses correctly. Defect fixed; full automated and Playwright E2E regression passed; manual exploratory pending 0 None DEF-20260621-001 resolved: limited user with Site/Media only triggered global plugin list fetch and plugin SSE from non-plugin admin layouts; expected no plugin API/SSE background work without plugin access; actual 403 console error from plugin-event-bridge during CAP-001; root cause hypothesis: layout-level plugin side effects ignored current user plugin capabilities; fix gates useInstalledEditorPlugins/usePluginEventBridge with canRunPluginBackgroundWork; verification: bun test src/__tests__/admin/accountPage.test.tsx, bun run test:e2e tests/e2e/users.e2e.ts, full bun test, bun run build, bun run lint, and bun run test:e2e passed. 2026-06-21 ADMIN-003 Global toolbar actions and account menu As an admin user, I want global actions like publish, open live page, settings, account, and workspace links to be available consistently. Toolbar renders brand, admin navigation, publish actions when relevant, open-live-page route from adminUi, settings modal, and account menu/logout. No active live path falls back to root; content selected entry publishes a posts route; leaving content clears the active live target; publish unavailable outside allowed contexts; account menu stays reachable; toolbar trailer stays contained at phone width. Interactive controls use UI primitives; admin links use in-house router; publish actions are capability and step-up gated. src/admin/pages/site/toolbar/Toolbar.tsx; src/admin/shared/AccountMenuButton; src/admin/shared/OpenLivePageButton; src/admin/state/adminUi.ts Site/content pages update active live path; other workspaces fall back. Happy: navigate workspaces, open account/settings, open live site root from Dashboard, open a selected Content entry path, and return to Dashboard root fallback. Error: logout failure handled lower-level. Boundary: plugin page active nav and missing active live path root fallback. Invalid: missing public site path falls back to root. Permission: publish hidden/disabled without caps. Performance: toolbar should not import editor store outside site. Mobile: toolbar text/buttons do not overlap. ADMIN-003 global toolbar trailer Playwright regression passed 2026-06-23; ADMIN-001 workspace navigation, ADMIN-004 Settings, AUTH-003 account logout/session, and publish/content affordance regressions also pass; logout failure and avatar-failure variants remain lower-level/future coverage 0 None admin-navigation.e2e.ts now verifies Settings/Open Live/account controls on Dashboard, Open Live root fallback, account menu Account & security/Sign out/Sign out all devices actions, Content selected-entry Open Live deep-linking to /posts/, target clearing back to root after leaving Content, and 390px toolbar trailer containment. Existing specs cover workspace navigation, Settings modal behavior, logout/session invalidation, and publish/content toolbar affordances. Verification: focused `bun run test:e2e -- --project=e2e tests/e2e/admin-navigation.e2e.ts -g "ADMIN-003"` passed 2/2 including setup; full `bun run test:e2e -- --project=e2e tests/e2e/admin-navigation.e2e.ts` passed 4/4 including setup; TSV 14-field check passed; `git diff --check` passed; `bun run lint` passed; `bun run build` passed; `bun test` passed 5701/5701. Run logs: docs/e2e/runs/2026-06-23-admin003-global-toolbar.md; docs/e2e/runs/2026-06-22-core-owner-lifecycle.md. 2026-06-23 @@ -24,7 +24,7 @@ ADMIN-005 Workspace layout, panels, toasts, and error recovery As an admin user, CAP-001 Workspace isolation and limited admin navigation As an owner or admin, I want limited users to reach only the admin workspaces their role grants so protected workspace data and actions stay hidden. Custom roles expose workspaces through capability checks in src/admin/access.ts. A user with site.read and media.read can reach Site, Media, and self-targeted Account; disallowed workspace links are hidden and direct disallowed admin routes redirect away without rendering protected screens or data. At 390px, the limited toolbar remains contained, the granted Media affordance and account trigger remain reachable, and denied Content/Users affordances remain absent. Users with no dashboard access fall through to the first accessible workspace; Account remains available to every authenticated user; role changes can leave stale client navigation until the current user refreshes; direct denied URLs must not flash protected content; background plugin work must not run without plugin workspace access; narrow toolbar must not reintroduce hidden denied workspaces or overflow. Workspace visibility is derived from canAccessWorkspace and firstAccessibleWorkspace; role capabilities are normalized by the Users role editor and enforced again by server route capability gates; denied direct navigation must not render protected headings or tables; mobile checks assert viewport size, document overflow, toolbar containment, granted Media link containment, and account trigger containment. src/admin/access.ts; src/admin/AuthenticatedAdmin.tsx; src/admin/pages/site/toolbar/Toolbar.module.css; tests/e2e/users.e2e.ts; src/__tests__/admin/capabilityAwareAdmin.test.tsx; server/auth/authz.ts The Playwright scenarios use disposable owner sessions to create View site + Browse media roles and users; Account reachability is code-defined self-service behavior; mobile assertions target the shared toolbar containment for restricted workspace sets. Happy: limited Site + Media user logs in and sees Media while staying out of Users/Content. Error: direct denied /admin/users navigation redirects away. Boundary: no dashboard capability falls through to Site. Invalid: stale or unknown workspace path must not expose protected UI. Permission: server APIs still require their own capabilities. Performance: no background plugin fetch/SSE for non-plugin roles. Mobile: limited user signs in at 390x844, sees Site and Media without denied Content/Users affordances, toolbar has no document-level horizontal overflow, Media and account controls remain contained, and Account menu opens with Account & security reachable. Focused CAP-001 desktop and mobile Playwright regressions passed 2026-06-23 0 None Verification: `bun run test:e2e -- --project=e2e tests/e2e/users.e2e.ts -g "CAP-001"` passed 3/3 including setup. The desktop scenario creates a custom View site + Browse media role, creates a limited user, signs in from a clean context, verifies Media is visible while Content and Users are absent, then opens `/admin/users` directly and verifies the route redirects away and the All Users heading is not rendered. The mobile scenario creates the same restricted persona, signs in at 390x844, verifies Site and Media are present while Content/Users are absent, verifies the toolbar and account trigger are viewport-contained without page-level horizontal overflow, opens Account menu, and verifies Account & security remains reachable. Related access rules live in `src/admin/access.ts`; Account remains self-targeted for every authenticated user by `canAccessWorkspace`. Run logs: docs/e2e/runs/2026-06-23-cap001-workspace-isolation.md; docs/e2e/runs/2026-06-23-cap001-mobile-limited-navigation.md. 2026-06-23 DASH-001 Dashboard widgets and metrics As an admin user, I want a dashboard with site metrics and quick status widgets so I can understand CMS state. /admin/dashboard renders the dashboard page with greeting/actions, onboarding facts, Overview header, Today/7d/30d/All range state, Customize/Add block controls, and the default nine first-party widgets: storage, pages, posts, media, status, activity, publish lineup, plugins, and domain. Dynamic widgets fetch their own dashboard endpoint and leave aria-busy once loaded; status and domain render static operational rows; the mobile Overview header stacks/wraps controls. Empty site and zero counts; per-widget API failure leaves that widget loading instead of crashing the dashboard; missing plugin widget definitions render skeleton placeholders; no saved layout preference falls back to the default layout; first-party range tabs are local state today; narrow viewports can overflow if header controls do not wrap. Dashboard workspace requires dashboard.read through admin access gates; widget endpoints require authenticated user or domain capability (media.read, plugins.read, audit.read); client responses are TypeBox-validated through apiRequest; dashboard layout preferences are schema-validated and recovered from invalid stored values. src/admin/pages/dashboard/DashboardPage.tsx; src/admin/pages/dashboard/DashboardPage.module.css; src/admin/pages/dashboard/widgets/index.ts; src/admin/pages/dashboard/hooks/useDashboardStats.ts; src/admin/pages/dashboard/hooks/useDashboardLayout.ts; server/handlers/cms/dashboard; tests/e2e/dashboard.e2e.ts The default E2E owner has dashboard, media, plugin, and audit capabilities; E2E storage label is SQLite; status/domain widget values are currently static placeholders; plugin-contributed widgets are outside the default clean-install grid. Happy: owner opens dashboard and sees all default first-party widgets with loaded data. Error: widget endpoint failure should not blank the page. Boundary: clean install with zero media/posts/plugins. Invalid: invalid saved layout falls back. Permission: limited users without widget capabilities do not receive protected widget data. Performance: widgets load progressively. Mobile: at 390px the Overview header, range tabs, Customize, and Add block controls fit without horizontal overflow. Focused DASH-001 Playwright regression passed 2026-06-23 after mobile header overflow fix 0 None DEF-20260623-DASH001-01 resolved: at 390px the Overview header kept title, range tabs, Customize, and Add block in one row; the Time range tablist right edge reached 451.5px in a 390px viewport. Root cause: DashboardPage.module.css gridHeader stayed row-oriented with a nowrap right control cluster. Fix: stack the dashboard header and wrap the right controls below 760px. Verification: bun run test:e2e -- --project=e2e tests/e2e/dashboard.e2e.ts -g "DASH-001". Run log: docs/e2e/runs/2026-06-23-dash001-dashboard-widgets.md. 2026-06-23 DASH-002 Dashboard customize grid As an admin user, I want to customize dashboard widgets so the dashboard fits my workflow. /admin/dashboard customize mode keeps the grid mounted, switches the header action to Done, widens grid gutters, exposes resize handles, shows the add tile, and opens the bottom Block library. The library shows only widgets not already on the dashboard; clicking AI usage appends it below existing widgets, debounced PUT saves the server-backed dashboard-layout user preference, reload restores it, dragging moves it to an empty cell, right-edge resize increases its span, dragging to the library pill removes it, and a final reload keeps it removed. At 390px the customize controls and Block library stay viewport-contained. Duplicate add is a no-op; explicit occupied drops are resolved down to the nearest empty row before commit; move to an occupied destination is rejected by the layout model; missing widget definitions reserve skeleton slots; all placed widgets show an Every block status; corrupted stored preferences log and keep the default layout; library height is clamped; block preview chrome must not create nested interactive elements. Layout constraints use MAX_COLS, MIN_COLS, MIN_ROWS, MAX_ROWS, snapToCell, hasOverlapAt, and TypeBox-validated dashboard-layout preferences; unknown preference keys are rejected; dashboard access requires dashboard.read; all preference responses are parsed through schema validation in the E2E test. src/admin/pages/dashboard/DashboardPage.tsx; src/admin/pages/dashboard/components/DashboardGrid.tsx; src/admin/pages/dashboard/components/BlockLibrary.tsx; src/admin/pages/dashboard/hooks/useDashboardLayout.ts; src/core/persistence/userPreferences.ts; src/ui/components/Widget/Widget.tsx; tests/e2e/dashboard.e2e.ts The clean E2E owner has the nine default dashboard widgets and the built-in AI usage widget registered but not placed; plugin-provided widgets are outside this clean-install regression; drag behaviour needs real browser coverage and uses pointer gestures. Happy: add AI usage from the Block library, verify saved preference payload, reload restored widget, drag move, resize, drag-to-library remove, and reload absence. Error: live previews must not emit nested-button console errors. Boundary: no available widgets after adding AI usage. Invalid: corrupted layout fallback remains lower-level/manual. Permission: dashboard workspace access required. Performance: debounced preference saves coalesce gestures. Mobile: at 390px customize controls, page width, library dialog, search, and add action stay contained. Focused DASH-002 Playwright regression passed 2026-06-23 after block-library nested-button fix 0 None DEF-20260623-DASH002-01 resolved: Block library live previews rendered widgets in view mode inside an Add widget button, so Widget added its own options button and React logged invalid nested button markup when AI usage appeared in the library. Root cause: LibraryItem used Render editing=false for preview chrome. Fix: render previews with editing=true so Widget emits a non-interactive drag handle inside the add/drag surface. Verification: bun run test:e2e -- --project=e2e tests/e2e/dashboard.e2e.ts -g "DASH-002"; run log docs/e2e/runs/2026-06-23-dash002-dashboard-customize.md. 2026-06-23 -DASH-003 Onboarding panel As a new admin, I want onboarding tasks that reflect setup progress so I know what to do next. /admin/dashboard defers onboarding rendering until setup facts load. When dashboard-layout onboardingDismissed is false, OnboardingPanel renders five tasks from useOnboardingState: identity (site name changed from Untitled Site or favicon), framework (site.settings.framework), first page (site.pages.length >= 2), plugin (installed plugin count), and team (users.length > 1). Clean E2E state shows 2 of 5 steps complete because site identity and the seeded first page are complete; framework is in progress and plugin/team are not started. Task actions either open Settings or route to /admin/site, /admin/plugins, and /admin/users. Dismiss saves dashboard-layout with onboardingDismissed true and reload keeps the panel hidden. At 390px the panel and Add members action stay contained. Facts endpoints can fail individually and Promise.allSettled falls back to defaults instead of bricking the dashboard; all tasks may be complete; dismissed panel stays hidden; corrupted dashboard-layout preference falls back through layout recovery; generated posts entry template makes firstPage complete in clean E2E; target workspaces may still be access-gated by route capabilities. Client API responses are validated through cmsAdapter, listCmsPlugins, and listCmsUsers; dashboard-layout preference schema includes onboardingDismissed boolean; dashboard route requires dashboard.read; target routes enforce their workspace capabilities. src/admin/pages/dashboard/DashboardPage.tsx; src/admin/pages/dashboard/components/OnboardingPanel.tsx; src/admin/pages/dashboard/hooks/useOnboardingState.ts; src/admin/pages/dashboard/hooks/useDashboardLayout.ts; src/core/persistence/userPreferences.ts; tests/e2e/dashboard.e2e.ts Clean E2E site name is Automated E2E Site; the posts entry template is seeded so firstPage is complete; owner has settings, site, plugins, and users access; no plugins or extra team members exist in clean E2E state. Happy: verify 2/5 progress, five task labels/states/actions, Settings modal action, workspace routes, server-backed dismiss save, and reload-hidden persistence. Error: facts API soft failure is covered by implementation inspection; focused browser regression does not stub each failed endpoint. Boundary: dismissed panel remains absent after reload. Invalid: corrupted preference fallback remains lower-level/manual. Permission: target routes remain behind workspace gates. Performance: panel waits for facts before rendering. Mobile: at 390px the panel and Add members action stay contained. Focused DASH-003 Playwright regression passed 2026-06-23 0 None Focused run first exposed a stale test-data assumption: clean E2E setup already has two pages because template seeding creates the posts entry template, so firstPage is complete and progress is 2/5. Verification: bun run test:e2e -- --project=e2e tests/e2e/dashboard.e2e.ts -g "DASH-003". Run log: docs/e2e/runs/2026-06-23-dash003-dashboard-onboarding.md. 2026-06-23 +DASH-003 Onboarding panel As a new admin, I want onboarding tasks that reflect setup progress so I know what to do next. /admin/dashboard defers onboarding rendering until setup facts load. When dashboard-layout onboardingDismissed is false, OnboardingPanel renders five tasks from useOnboardingState: identity (site name changed from Untitled Site or favicon), framework (site.settings.framework), first page (site.pages.length >= 2), plugin (installed plugin count), and team (users.length > 1). Clean E2E state shows 1 of 5 steps complete because only site identity is complete; framework is in progress and first page/plugin/team are not started. Task actions either open Settings or route to /admin/site, /admin/plugins, and /admin/users. Dismiss saves dashboard-layout with onboardingDismissed true and reload keeps the panel hidden. At 390px the panel and Add members action stay contained. Facts endpoints can fail individually and Promise.allSettled falls back to defaults instead of bricking the dashboard; all tasks may be complete; dismissed panel stays hidden; corrupted dashboard-layout preference falls back through layout recovery; later test-created pages must not pollute clean-install facts; target workspaces may still be access-gated by route capabilities. Client API responses are validated through cmsAdapter, listCmsPlugins, and listCmsUsers; dashboard-layout preference schema includes onboardingDismissed boolean; dashboard route requires dashboard.read; target routes enforce their workspace capabilities. src/admin/pages/dashboard/DashboardPage.tsx; src/admin/pages/dashboard/components/OnboardingPanel.tsx; src/admin/pages/dashboard/hooks/useOnboardingState.ts; src/admin/pages/dashboard/hooks/useDashboardLayout.ts; src/core/persistence/userPreferences.ts; tests/e2e/dashboard.e2e.ts Clean E2E site name is Automated E2E Site; setup seeds only Home, so firstPage remains not started; owner has settings, site, plugins, and users access; no plugins or extra team members exist in clean E2E state. Happy: verify 1/5 progress, five task labels/states/actions, Settings modal action, workspace routes, server-backed dismiss save, and reload-hidden persistence. Error: facts API soft failure is covered by implementation inspection; focused browser regression does not stub each failed endpoint. Boundary: dismissed panel remains absent after reload. Invalid: corrupted preference fallback remains lower-level/manual. Permission: target routes remain behind workspace gates. Performance: panel waits for facts before rendering. Mobile: at 390px the panel and Add members action stay contained. Dashboard preflight Playwright regression passed 2026-07-11 0 None Full-suite release gating exposed the stale fixture assumption: setup seeds only Home, so firstPage is not started and clean progress is 1/5. `dashboard-preflight` now runs all dashboard scenarios immediately after setup and before persona/plugin mutations; the focused 2026-07-11 run passed DASH-001, DASH-002, and DASH-003 before the dependent persona project. 2026-07-11 SPOT-001 Spotlight open/search/navigation As an admin user, I want a command palette so I can quickly navigate and run actions. Cmd/Ctrl+K opens Spotlight, focuses input, searches built-in and provider commands, Enter runs navigation/action, Esc closes and restores focus. No matches; repeated open; route changes while open; disabled workspace command; keyboard order. Commands are registered through commandRegistry/scopes; actions check capabilities; provider results validated with schemas. src/admin/spotlight; src/admin/spotlight/builtinCommands.ts; src/admin/spotlight/providerRunner.ts Browser keyboard events differ by OS; tests use platform-neutral shortcuts where possible. Happy: open, search, navigate. Error: provider failure shown/ignored. Boundary: empty query and no-match query. Invalid: unknown command id. Permission: inaccessible commands hidden/disabled. Performance: search responsive with providers. Mobile: keyboard/accessibility focus works. SPOT-010 async skeleton full command-palette regression passed 2026-06-22 after stale async provider state fix 0 None SPOT-010 promoted: hold the Content provider search request, verify the Content skeleton group is busy, release the request, and verify the Async Skeleton Probe result replaces the skeleton. Defect E2E-20260622-SPOT010-01 fixed: stale empty asyncResults from the palette's initial empty query suppressed the later skeleton for the same provider; `SET_QUERY` now clears asyncResults/loadingProviders. Prior promotions remain covered for SPOT-009 nested panel focus, SPOT-011 keyboard-only execution, SPOT-012 reduced motion, and SPOT-013 high contrast. Verification: TSV and diff guards passed; focused reducer red/green passed; focused SPOT-010 E2E passed; full command-palette E2E passed 14/14; bun run lint passed; bun run build passed; bun test passed 5496/5496. 2026-06-22 SPOT-002 Spotlight scoped commands and pending actions As an admin user, I want scoped palette flows for pages, layers, breakpoints, data, media, users, and plugins so actions happen in context. Selecting a scope pushes subcommands; some commands set pending actions consumed by destination workspace after navigation; editor commands mutate active editor state. Context missing selected node/page; destination loads slowly; pending action consumed once; stale pending action after failed nav. Scopes and pending actions are typed in spotlight files; command capabilities gate actions; destructive commands use confirmation flow. src/admin/spotlight/scopes; src/admin/spotlight/commands; src/admin/spotlight/pendingAction.ts; src/admin/pages/site/SitePage.tsx Some commands require hydrated editor store. Happy: create page/VC from palette. Error: missing context command disabled. Boundary: pending action survives nav load. Invalid: malformed args ignored. Permission: role cannot run disallowed command. Performance: provider lookup bounded. Mobile: palette remains usable. SPOT-007 selected-layer context-ranking regression and full command-palette E2E file passed 2026-06-22; broader pending-action exploratory pending 0 None SPOT-007 promoted in `command-palette.e2e.ts`: Duplicate layer is absent outside editor context, a newly inserted text layer exposes selected-layer controls, and Duplicate layer appears in the top five empty-palette options. Verification: focused selected-layer E2E, full `tests/e2e/command-palette.e2e.ts`, and `bun run lint` passed. 2026-06-22 SPOT-003 Spotlight recents and destructive confirmation As an admin user, I want recent commands and protection for destructive commands so speed does not compromise safety. Recent commands persist and appear on reopen; destructive commands require two Enter confirmations and timeout collapse after configured duration. Timer expiry; command disappears after state change; repeated Enter double-fire; recents dedupe. Recent store validates local storage; destructive command state owned in Spotlight state; no native confirm dialogs. src/admin/spotlight/recentStore.ts; src/admin/spotlight/state.ts; src/admin/spotlight/SpotlightResults.tsx Confirm timeout is now Playwright-covered; OS accessibility variants remain observational. Happy: recent command appears. Error: destructive command cancel/timeout no mutation. Boundary: confirmation at exactly timeout. Invalid: corrupted recent store. Permission: destructive command still capability gated. Performance: recents load instantly. Mobile: confirm row visible. SPOT-005 destructive confirm-timeout Playwright regression and full command-palette E2E file passed 2026-06-22; broader context-ranking and accessibility exploratory pending 0 None SPOT-005 promoted in `command-palette.e2e.ts`: first Enter arms Delete current page, timeout clears the prompt, and the throwaway page remains. Verification: focused timeout E2E, full `tests/e2e/command-palette.e2e.ts`, and `bun run lint` passed. 2026-06-22 @@ -45,7 +45,7 @@ SITE-014 Site dependencies and runtime package resolution As a site editor, I wa SITE-015 Site editor media explorer and picker As a site editor, I want to browse, upload, reuse, inspect, edit, and apply media from inside the site editor so image, video, SVG, and background/media controls can use CMS assets without leaving the authoring workflow. MediaLibraryControl renders library and URL modes for image/video props, lazy-loads MediaPickerModal on Browse, filters by media kind, updates the prop with the picked asset publicPath, supports clearing, and opens MediaViewerWindow for the selected CMS asset. MediaExplorerPanel is a docked left-rail panel that lists CMS assets grouped as Images, Videos, and Other, supports search and list/grid view persistence, uploads assets through CMS media APIs, opens the shared viewer, exposes context menu actions for Copy URL, Rename, Delete, and conditionally Use in selected image/video when the selected canvas node matches the asset kind. Published pages render selected local media through /uploads URLs. Empty library shows bucket empty states; image/video/other assets are bucketed by MIME type; selected image/video actions only appear for matching module and asset kind; unsupported uploads return an alert through the media upload queue; deleted or missing selected paths fall back to saved-path labels; URL mode accepts local /uploads paths plus http/https URLs and rejects invalid image/video URLs; SVG uploads are sanitized before serving; viewer edits/removal update local asset state; upload queue and media viewer can be closed without leaving the editor. CMS media responses are validated by @core/persistence/cmsMedia; uploads are server magic-byte/type/size checked and routed through the media presentation pipeline; MediaLibraryControl validates URL mode before calling onChange; MediaExplorerPanel applies assets only to selected base.image src or base.video videoUrl props; media routes require the relevant media capabilities; publisher escapes/render-validates media URLs and visitor pages must not include admin chrome. src/admin/pages/site/panels/MediaExplorerPanel/MediaExplorerPanel.tsx; src/admin/pages/site/panels/MediaExplorerPanel/mediaExplorerUtils.ts; src/admin/pages/site/property-controls/MediaLibraryControl.tsx; src/admin/pages/site/property-controls/ImageControl.tsx; src/admin/pages/site/property-controls/BackgroundImageControl.tsx; src/admin/pages/media/components/MediaPickerModal/MediaPickerModal.tsx; src/admin/pages/media/components/MediaViewerWindow/MediaViewerWindow.tsx; src/admin/pages/media/hooks/useStandaloneMediaEditor.ts; src/core/persistence/cmsMedia.ts; server/handlers/cms/media.ts; server/handlers/cms/mediaUpload.ts; src/modules/base/image; src/modules/base/video The docked Media Explorer and the property-control picker intentionally share CMS media and viewer primitives with the Media workspace; direct background-image picker publishing remains covered by lower-level style/publisher tests rather than a dedicated browser journey; the new SITE-015 browser regression uses disposable SQLite/uploads and verifies public media output as a visitor. Happy: upload an image through the property picker, select it for an image module, save, publish, and verify public /uploads image decoding; reuse the same library asset on a second image without re-upload; upload an image through the docked Media Explorer, use its context menu to apply it to the selected image module, save, publish, and verify public /uploads image decoding. Error: unsupported upload shows specific rejection feedback; media API errors surface inline or restore optimistic state. Boundary: empty library, search filters, list/grid view, image/video/other grouping, selected image/video context action gating, metadata rename/edit/reload persistence, replace/delete/restore/purge lifecycle, and sanitized SVG serving. Invalid: bad URL-mode values are rejected before prop update; unsafe SVG script/event/style content is stripped. Permission/security: media APIs require media capabilities and public visitor pages show only uploaded media, not admin chrome. Performance: MediaPickerModal lazy-loads only after Browse; media panel fetches assets when opened. Mobile/responsive: media viewer, replace dialog, trash restore, and storage panel have mobile containment coverage; docked editor Media Explorer mobile remains a residual exploratory check. SITE-015 browser regression passed 2026-06-23 with direct docked Media Explorer apply-to-selected-image coverage; no open defects; docked Media Explorer mobile exploratory remains pending 0 None Added SITE-015 coverage to `tests/e2e/media.e2e.ts`: create a page, insert/select an image module, open the Media panel, upload an image through the docked panel, use the asset context menu `Use in selected image`, save, publish, and verify the public page serves/decodes the uploaded image. Existing coverage in `media.e2e.ts` covers picker upload/select/publish, asset reuse, unsupported upload rejection, metadata persistence, mobile metadata viewer containment, replace/delete/restore/purge lifecycle, mobile lifecycle containment, storage panel state/mobile containment, and SVG sanitization. Focused component coverage in `siteExplorerPanel.test.tsx` covers Media Explorer grouping, search/list/grid, copy URL, selected image/video apply actions, viewer opening, rename, and delete. Verification this slice: TSV integrity guard passed; focused SITE-015 unit/component/API `bun test` passed 102/102; focused `bun run test:e2e -- --project=e2e tests/e2e/media.e2e.ts -g "SITE-015"` passed 2/2 including setup; full `bun run test:e2e -- --project=e2e tests/e2e/media.e2e.ts` passed 12/12 including setup; `bun run lint` passed; `bun run build` passed; full `bun test` passed 5697/5697. Run log: docs/e2e/runs/2026-06-23-site015-media-explorer.md. 2026-06-23 SITE-016 Preview overlay and live-page opening As a site editor, I want to preview the current draft and open the live route so I can compare draft and published output. Preview page is exposed from the publish-actions menu; PreviewOverlay mounts only when previewOpen, requires an active site and active page, renders publishPage(activePage, site, registry) into a sandboxed iframe srcDoc, shows the active page title, closes by Close button/Escape/backdrop, and restores focus. OpenLivePageButton is globally mounted in the toolbar, reads adminUi.activeLivePath, and opens that path in a new noopener/noreferrer tab or falls back to the site root. useActiveLivePath publishes regular page paths, template preview targets, post-type preview permalinks, or /404 for not-found templates. No active site/page renders no overlay; unpublished saved drafts can preview without changing public output; live route shows last published artefact until publish; activeLivePath null opens root; template pages are not directly routable and resolve to their preview target; popup uses the current dev/admin origin but Vite proxies public routes; narrow viewport must keep preview reachable and document width contained. Preview state is owned by uiSlice openPreview/closePreview; overlay generation trusts the validated in-memory site document and the publisher boundary sanitizes emitted HTML; iframe uses sandbox="" with no allow flags; OpenLivePageButton receives the already-resolved public path from adminUi; save/publish remain capability and step-up gated by surrounding toolbar flows. src/admin/pages/site/toolbar/PublishButton.tsx; src/admin/pages/site/toolbar/PublishActionGroup.tsx; src/admin/pages/site/preview/PreviewOverlay.tsx; src/admin/pages/site/store/slices/uiSlice.ts; src/admin/pages/site/hooks/useActiveLivePath.ts; src/admin/shared/OpenLivePageButton/OpenLivePageButton.tsx; src/core/publisher; src/core/page-tree/page.ts Public live routes intentionally show the last published version, not the saved draft; this slice covers regular pages, while template/content-entry live-path permutations remain covered lower-level or future browser coverage. Happy: create page, publish version A, save draft version B, open Preview page and verify iframe shows B, open live page and verify popup route shows A without admin chrome. Error: no-site/no-active-page overlay and close behaviours covered by component tests. Boundary: activeLivePath root fallback, home path, content entry path, template target resolver, and mobile 390px preview reachability. Invalid: missing runtime preview body remains covered by server runtime tests, not this client overlay. Permission/security: publish/save capability and step-up gates surround the flow; public popup has no admin chrome. Performance: preview lazy-loads publisher graph only when opened. Mobile: overlay opens at 390px without document overflow. SITE-016 automated browser audit passed 2026-06-23 with draft-vs-live comparison and mobile preview reachability; no open defects; template/content live-path browser permutations remain residual risk 0 None Added `tests/e2e/preview-live.e2e.ts`: publish a disposable page with text A, save draft text B without publishing, verify Preview page iframe shows B and not A, verify toolbar Open live page popup shows A and not B without editor chrome, then reopen Preview page at 390x844 and verify no document overflow. Initial focused run failed because the new spec selected a layer while the Site Explorer was open; root cause was a spec precondition, fixed by opening the Layers panel before selecting the Text node. Verification this slice: TSV integrity guard passed; focused preview/live unit suite passed 52/52; focused `bun run test:e2e -- --project=e2e tests/e2e/preview-live.e2e.ts -g "SITE-016"` passed 2/2 including setup; `bun run lint` passed; `bun run build` passed; full `bun test` passed 5697/5697. Run log: docs/e2e/runs/2026-06-23-site016-preview-live.md. 2026-06-23 SITE-017 Visual components and slots As a site editor, I want to componentize authored page content, add reusable slots, fill those slots on a page, and publish the resulting page as clean visitor HTML. Componentize converts the selected page node into a Visual Component row with a base.body definition root, replaces the original page node with a base.visual-component-ref, and switches the editor into VC mode. Adding a base.slot-outlet to the VC definition creates a locked base.slot-instance child on the page ref when returning to the page. The locked slot row hides destructive structural actions but accepts inserted child content. Save writes component rows before page rows so newly-created component refs validate; publish inlines the component definition and slot fill into the public artefact without editor slot labels or component names. Duplicate or blank component names; conversion attempted from VC mode, body/root, or an existing component ref; recursive refs; unknown or missing componentId; slot outlet rename/reorder/delete; empty or default slots; nested refs; page save racing a new component save; save/publish/reload after slot fill insertion. Component names and recursion use typed VisualComponent errors; VC/page shapes are TypeBox-validated through component/page adapters; syncSlotInstances materializes locked slot instances from slot outlets; dirty tracking marks both edited page and created component; CmsAdapter writes components before pages; publisher renderVisualComponentRef resolves refs, expands slot-instance children at matching outlets, and sanitizes emitted HTML. src/admin/pages/site/panels/PropertiesPanel/ConvertToComponentButton.tsx; src/admin/pages/site/store/slices/visualComponentsSlice.ts; src/admin/pages/site/store/slices/site/dirtyTracking.ts; src/core/persistence/cms.ts; server/handlers/cms/pages.ts; server/handlers/cms/components.ts; src/core/visualComponents; src/modules/base/visualComponentRef; src/modules/base/slotOutlet; src/modules/base/slotInstance; src/core/publisher/renderVisualComponentRef.ts Visual Components persist as rows in the components system table; page rows can reference a component only after that component row is stored; E2E uses disposable local SQLite/uploads and fresh owner login because publish rotates the session. Happy: componentize Text, create a slot outlet, return to page, insert Text into the locked slot, save, publish, and verify anonymous public page contains component body plus slot fill. Error: adapter ordering regression prevented new component refs from validating during page save. Boundary: locked slot row remains operable while hiding Rename/Duplicate/Cut/Delete; empty/default/nested/unknown component behavior covered by lower-level suites. Invalid: blank/duplicate names and recursive refs rejected by focused tests. Permission: publish step-up exercised; fine-grained capability variants remain lower-level/future browser coverage. Performance: save ordering is sequential only where required, layouts remain independent. Mobile: VC publish journey desktop-covered; mobile VC editing remains residual. SITE-017 browser regression passed 2026-06-23 after DEF-20260623-SITE017-001 fix; no open high/critical defects for this feature slice; mobile and permission permutations remain residual 0 None DEF-20260623-SITE017-001 fixed: publishing a freshly componentized page could emit an empty body because CmsAdapter saved pages and components in parallel, and the pages endpoint stripped the new base.visual-component-ref as dangling when it validated before the component row committed. Fix: write /admin/api/cms/components before starting /admin/api/cms/pages. Added `tests/e2e/visual-builder.e2e.ts` SITE-017 public publish journey, `src/__tests__/persistence/cmsAdapter.test.ts` ordering regression, and `src/__tests__/editor-store/dirtyTracking.test.ts` page+component dirty-mark guard. Verification passed: TSV integrity guard, focused VC suite 243/243, cmsAdapter 11/11, dirtyTracking 29/29, Playwright SITE-017 2/2 including setup, `bun run lint`, `bun run build`, and full `bun test` 5699/5699. Run log: docs/e2e/runs/2026-06-23-site017-visual-components.md. 2026-06-23 -SITE-018 Templates and dynamic bindings As a site editor/content author, I want page templates and bindings so data rows render through site pages. Site Explorer creates template pages through Template settings, stores enabled template target and priority on the page row, opens the template in canvas mode, and shows synthetic preview data for postType templates. DynamicBindingControl auto-scopes string props on postTypes templates to the targeted table, inserts `{currentEntry.title}` tokens into text props, and `base.outlet` implicitly binds currentEntry.body. Save Draft persists template state, Publish snapshots it, and public `/posts/:slug` routes render the highest-priority matching published template with the published row title and body. No matching template or row; seeded default template tie/order; deleted target table/field; no compatible fields; empty/null title/body values; unpublished template or row; duplicate template slug; invalid binding path; missing outlet; loops nested inside templates. TemplateSettingsDialog requires nonblank title, unique normalized slug, numeric priority, and at least one selected post type when target kind is postTypes. PageTemplateConfig is TypeBox-parsed from page rows; binding picker filters fields by control compatibility; token interpolation and dynamic prop resolution tolerate unknown/empty fields; `base.outlet` body HTML is sanitized at the publisher boundary; public entry routes use published rows and published site snapshots only. src/core/page-tree/pageTemplate.ts; src/core/templates/templateMatching.ts; src/core/templates/templatePreviewData.ts; src/core/templates/dynamicBindings.ts; src/admin/shared/dialogs/TemplateSettingsDialog/TemplateSettingsDialog.tsx; src/admin/pages/site/canvas/TemplateModeControl.tsx; src/admin/pages/site/property-controls/DynamicBindingControl; src/modules/base/outlet; tests/e2e/visual-builder.e2e.ts; src/__tests__/templates; src/__tests__/server/cmsTemplateRoutes.test.ts Seeded Posts and its default entry template exist in disposable E2E state; the SITE-018 browser regression creates a higher-priority Posts template so it wins deterministically; content row publishing is step-up gated and runs from a fresh session. Happy: create a Posts template, bind title/body, save/publish, publish a post, and verify public route. Error: no matching row/template falls back or 404s in lower-level route tests. Boundary: seeded default template loses to higher priority; template preview uses synthetic Example Post data before real rows exist. Invalid: empty postTypes selection, invalid priority/slug, incompatible binding fields. Permission: site edit plus content publish capabilities required; persona splits remain pending. Performance: focused E2E completes template preview and publish within Playwright timeouts. Mobile: template controls still need narrow-viewport authoring coverage. SITE-018 Playwright browser regression and full visual-builder E2E passed 2026-06-23; no product defects found; mobile template authoring, permission personas, and custom table/field binding permutations remain residual risk 0 None SITE-018 promoted in `visual-builder.e2e.ts`: create a high-priority Posts template from the Site panel, set Template settings to Post types/Posts, insert Text, use the binding picker to add `{currentEntry.title}`, verify the canvas previews `Template headline: Example Post Title`, insert `base.outlet` and verify synthetic body preview, save/publish the template snapshot, create/save/publish a post, and verify an anonymous visitor route `/posts/` renders the template prefix, published title, and body while hiding unresolved tokens and synthetic preview text. Initial failures were test-selector/value-surface assumptions, not product defects. Verification: focused `bun run test:e2e -- --project=e2e tests/e2e/visual-builder.e2e.ts -g "SITE-018"` passed 2/2 including setup; full `bun run test:e2e -- --project=e2e tests/e2e/visual-builder.e2e.ts` passed 19/19. Run log: docs/e2e/runs/2026-06-23-site018-templates-bindings.md. 2026-06-23 +SITE-018 Templates and dynamic bindings As a site editor/content author, I want page templates and bindings so data rows render through site pages. Site Explorer creates template pages through Template settings, stores enabled template target and priority on the page row, opens the template in canvas mode, and shows synthetic preview data for postType templates. DynamicBindingControl auto-scopes string props on postTypes templates to the targeted table, inserts `{currentEntry.title}` tokens into text props, and `base.outlet` implicitly binds currentEntry.body. Save Draft persists template state, Publish snapshots it, and public `/posts/:slug` routes render the highest-priority matching published template with the published row title and body. No matching template or row; competing template priority/tie order; deleted target table/field; no compatible fields; empty/null title/body values; unpublished template or row; duplicate template slug; invalid binding path; missing outlet; loops nested inside templates. TemplateSettingsDialog requires nonblank title, unique normalized slug, numeric priority, and at least one selected post type when target kind is postTypes. PageTemplateConfig is TypeBox-parsed from page rows; binding picker filters fields by control compatibility; token interpolation and dynamic prop resolution tolerate unknown/empty fields; `base.outlet` body HTML is sanitized at the publisher boundary; public entry routes use published rows and published site snapshots only. src/core/page-tree/pageTemplate.ts; src/core/templates/templateMatching.ts; src/core/templates/templatePreviewData.ts; src/core/templates/dynamicBindings.ts; src/admin/shared/dialogs/TemplateSettingsDialog/TemplateSettingsDialog.tsx; src/admin/pages/site/canvas/TemplateModeControl.tsx; src/admin/pages/site/property-controls/DynamicBindingControl; src/modules/base/outlet; tests/e2e/visual-builder.e2e.ts; src/__tests__/templates; src/__tests__/server/cmsTemplateRoutes.test.ts The Posts table is seeded but entry templates are author-owned; SITE-018 publishes its own preview row and creates a priority-300 Posts template so it wins over lower-priority content fixtures deterministically. Content row publishing is step-up gated and runs from a fresh session. Happy: publish a post, create a Posts template, select that row as Preview source, bind title/body, save/publish, and verify the public route. Error: no matching row/template falls back or 404s in lower-level route tests. Boundary: the explicitly selected real preview row wins over newer unrelated posts, and the priority-300 template wins over lower-priority fixtures. Invalid: empty postTypes selection, invalid priority/slug, incompatible binding fields. Permission: site edit plus content publish capabilities required; persona splits remain pending. Performance: focused E2E completes template preview and publish within Playwright timeouts. Mobile: template controls still need narrow-viewport authoring coverage. SITE-018 focused Playwright regression passed 2026-07-11; real-row preview selection and competing-template priority are deterministic; mobile authoring and permission personas remain residual risk 0 None The release-gate regression now publishes a disposable post first, creates a priority-300 Posts template, explicitly selects that post through Preview source, verifies its title/body in canvas, publishes the template, and verifies the anonymous route without unresolved tokens. The 2026-07-11 sequential focus run passed SITE-018 together with lower-priority content-template fixtures. 2026-07-11 SITE-019 Saved layouts As a site editor, I want to save and reuse layouts so common structures can be inserted quickly. Layouts are stored in the layouts system table; the Save as layout dialog rejects blank and duplicate names; the module inserter lists saved layouts under Layouts; inserting a saved layout clones the captured subtree and style rules with fresh node ids; renaming or deleting the saved layout does not affect already-inserted page content. Duplicate layout names; blank names; saved layout source on page root; stale selections after page creation; mobile/narrow inserter category labels; context-menu z-index inside the spotlight inserter; deleted modules/VC refs inside a layout; invalid tree; deleting a saved layout already used by a page has no page effect. Layout route validates layout document; clone remaps node ids/scoped classes; system table locked from rename/delete; addPage clears stale canvas selection; module inserter category buttons keep explicit accessible names when labels hide responsively; saved-layout manage menu renders above the spotlight layer. server/handlers/cms/layouts.ts; src/admin/pages/site/dialogs/LayoutNameDialog.tsx; src/admin/pages/site/module-picker/ModuleInserterDialog.tsx; src/admin/pages/site/module-picker/SavedLayoutManageMenu.tsx; src/admin/pages/site/store/slices/layoutsSlice.ts; src/admin/pages/site/store/slices/site/pageActions.ts; src/core/data/layoutFromRow.ts Layouts are not separate DB tables; selector selection can persist across page switches, but stale node selection must not. Happy: save styled container subtree as layout, insert it on a new page, save/reload/publish, verify public output. Error: blank and duplicate names render inline errors. Boundary: narrow 390px inserter exposes Layouts. Invalid: stale node selection is cleared on addPage. Permission/security: structure edit and system table gates remain server-covered. Performance: picker remains responsive with saved item. Manage: rename and delete saved layout; inserted content persists. SITE-019 Playwright browser regression passed 2026-06-23 after DEF-20260623-SITE019-001/002/003 fixes; store selection and toolbar category accessibility regressions passed; no open high/critical defects in this slice; plugin-pack grouping, VC-mode cycle blocking, invalid/dangling layout snapshots, permission personas, and real touch/long-press mobile management remain residual risk 0 None DEF-20260623-SITE019-001 fixed: creating a new page via addPage left selectedNodeId/hover/inline-edit state from the previous page, keeping the right inspector expanded with no valid selected element and blocking narrow-width canvas controls. Fix: addPage reuses clearCanvasSelectionDraft; regression `src/__tests__/editor-store/pageActionsSelection.test.ts`. DEF-20260623-SITE019-002 fixed: module inserter category buttons lost accessible names at <=720px because visible labels were display:none and icons were aria-hidden, leaving count-only buttons. Fix: explicit aria-label on category buttons; regression in `src/__tests__/toolbar/modulePickerDropdown.test.tsx`. DEF-20260623-SITE019-003 fixed: saved-layout Rename/Delete context menu rendered at z-index 1000 under the spotlight inserter at z-index 9000, making menu items visible to accessibility APIs but unclickable. Fix: saved-layout manage menu uses zIndex 10000. Added `visual-builder.e2e.ts` SITE-019 browser journey covering blank/duplicate validation, mobile Layouts category reachability, insert, style preservation, rename, delete, save/reload, publish, and anonymous public output. Verification: focused `bun test src/__tests__/editor-store/pageActionsSelection.test.ts`, `bun test src/__tests__/toolbar/modulePickerDropdown.test.tsx`, and `bun run test:e2e -- --project=e2e tests/e2e/visual-builder.e2e.ts -g "SITE-019"` passed. Run log: docs/e2e/runs/2026-06-23-site019-saved-layouts.md. 2026-06-23 SITE-020 HTML import modal As a site editor, I want to paste/import HTML so existing markup becomes editable page nodes. ImportHtmlModal parses HTML, strips unsafe content, maps elements to page nodes/modules, and inserts fragment into active tree. SVG mapping; malformed HTML; unsupported tags; script/style stripping; text normalization. HTML import uses parser/stripUnsafe rules and module mapping; insertion still uses tree mutation legality. src/admin/modals/ImportHtml; src/core/htmlImport Imported styles may need manual class cleanup. Happy: paste simple HTML becomes nodes. Error: malformed HTML feedback. Boundary: empty fragment. Invalid: script tag stripped. Permission: structure edit. Performance: large paste bounded. Mobile: modal usable. Automated baseline, build, lint, bundle, and Playwright E2E passed; manual exploratory pending 0 None 2026-06-21 SITE-021 Super Import site/bundle wizard As an operator, I want to import static-site files or CMS bundles so I can move content into Instatic. SiteImport modal accepts drops, analyzes files/bundles, shows conflicts/review, supports import strategies, applies asset rewrites/stylesheets/fonts, and emits refresh events. Unsupported archive; path traversal; conflicting slugs/files; replace strategy destructive; invalid bundle schema. Import preview/import routes validate SiteBundle and import strategy; replace/import requires data.import and sometimes content.manage/step-up; path traversal tests gate. src/admin/modals/SiteImport; server/handlers/cms/importPreview.ts; server/handlers/cms/import.ts; src/core/siteImport; src/core/data/bundleSchema.ts Use disposable DB for replace import. Happy: import small CMS bundle. Error: invalid zip/bundle. Boundary: merge-add vs merge-overwrite vs replace. Invalid: traversal path. Permission: data.import/content.manage and step-up. Performance: progress shown. Mobile: wizard scrolls. CMS-bundle replace import step-up Playwright regression and SiteImportModal unit suite passed 2026-06-22; static-file import exploratory still pending 0 None DEF-20260622-012 resolved: replace import hit the server step-up gate but useCmsBundleImport caught `step_up_required` as a generic import failure toast, leaving fresh sessions unable to complete destructive imports. Fix wraps importSiteBundle in runStepUp, keeps cancel non-destructive/non-error, and retries after successful step-up. Reverified 2026-06-22 and normalized stale high-severity accounting: destructive site import step-up E2E passed 2/2 including setup, and the focused auth/site-import/data unit bundle passed 79/79. Static-file import exploratory remains untested risk, not a known open high defect. Verification: `bun run test:e2e -- --project=e2e tests/e2e/capabilities.e2e.ts -g "destructive site import requires successful step-up"` and `bun test src/__tests__/admin/siteImport/SiteImportModal.test.tsx`. Run logs: docs/e2e/runs/2026-06-22-cap003-destructive-import-step-up.md; docs/e2e/runs/2026-06-22-high-severity-defect-accounting.md. 2026-06-22 @@ -67,7 +67,7 @@ CONTENT-001 Content workspace collections and entry list As a content author, I CONTENT-002 Entry CRUD and metadata As a content author, I want to create, rename, duplicate, delete, move, and edit entry metadata so content can be managed. Handlers create untitled entries, rename title/slug, duplicate current draft state, delete with next selection, move collection, reassign author, and save SEO/featured media/body cells. Duplicate slug; deleting selected row; moving to incompatible collection; unsaved draft duplication; author missing. Entry operations check canCreateContent/canEditContentEntry/canEditAnyContent; row APIs validate cells/status/table; title slug derives from cells/slug. src/admin/pages/content/ContentPage.tsx; src/admin/pages/content/utils/entryOp.ts; server/handlers/cms/data/rows.ts Ownership affects own-vs-any permissions. Happy: create/edit/save/delete entry. Error: duplicate slug. Boundary: blank title fallback. Invalid: invalid cells. Permission: own author cannot edit others. Performance: saving state never stuck. Mobile: metadata fields fit. Automated baseline, build, lint, bundle, and Playwright E2E passed; manual exploratory pending 0 None 2026-06-21 CONTENT-003 Rich body editor, slash menu, media, and tokens As a content author, I want rich body editing with media and data tokens so posts can contain structured content. Tiptap editor supports markdown/rich body editing, slash menu inserts heading/text/media/data token placeholder, media picker inserts media nodes, and body draft saves to row cells. Unsupported media node; editor lazy load failure; token picker currently placeholder-based; markdown/html sanitization; empty body. Body cell type follows data field richText/body schema; media node attrs validated by editor code; save runs through row update schema. src/admin/pages/content/TiptapBodyEditor.tsx; src/admin/pages/content/components/BodySlashMenu; src/admin/pages/content/nodes; src/core/data/cells.ts Token picker currently inserts the placeholder `{currentEntry.title}` until the binding picker is reintegrated. Happy: type body and insert image. Error: media picker failure. Boundary: empty/large body. Invalid: unsafe HTML sanitized on render/publish. Permission: edit entry required. Performance: editor lazy loads. Mobile: fullscreen/content canvas usable. CONTENT-003 slash-menu heading/data-token Playwright regression passed 2026-06-22; media picker and sanitization browser edges remain pending 0 None CONTENT-003 promoted through a focused content workspace regression in `content.e2e.ts`: create a post, open the Tiptap body editor, type `/h2`, choose Heading 2 from the slash menu, author `Release notes`, type `/data`, choose Data token, verify `{currentEntry.title}` appears, save the draft, reload, reselect the entry, and verify the heading plus token persist in the body editor. Verification: focused `bun run test:e2e -- --project=e2e tests/e2e/content.e2e.ts -g "CONTENT-003"` passed 2/2 including setup; full `bun run test:e2e -- --project=e2e tests/e2e/content.e2e.ts` passed 5/5 including setup; TSV 14-column guard, diff whitespace guard, and targeted stale-CONTENT search passed; `bun run lint` passed; `bun run build` passed; `bun test` passed 5496/5496. Run log: docs/e2e/runs/2026-06-22-content003-rich-body-slash.md. 2026-06-22 CONTENT-004 Entry publish, status, and schedule As a content publisher, I want to publish, unpublish, draft, or schedule entries so public routes reflect editorial state. Content toolbar/status controls call publish/status/schedule endpoints, require publish permission, update row status/version, and public route only exposes published rows. Scheduled in past; author own/any permissions; unpublish after publish; schedule delete; publish failure leaves draft. Status union is draft/published/unpublished/scheduled; schedule endpoint requires target datetime; canPublishContentEntry controls UI. server/handlers/cms/data/rows.ts; src/core/persistence/cmsData.ts; src/admin/pages/content/ContentPage.tsx; src/admin/modals/SchedulePublishDialog Content publish is row-level, separate from full site publish where applicable. Happy: publish entry and public route appears. Error: no permission. Boundary: scheduled time. Invalid: unsupported status. Permission: own vs any publish. Performance: status updates promptly. Mobile: status controls usable. Automated baseline, build, lint, bundle, and Playwright E2E passed; manual exploratory pending 0 None 2026-06-21 -CONTENT-005 Content live canvas and template mode As a content author, I want to preview an entry inside its site template so I can see how it will look. Content mode toggles write/live; LiveCanvas renders entry via selected template/public path, mirrors active live path to toolbar, and uses current draft body/media/SEO state. No template configured; route base missing; public path with blank slug; draft changes vs live published. Template config/row data validated; content mode is client-side display only; public path derives from routeBase+slug. src/admin/pages/content/components/LiveCanvas; src/admin/pages/content/components/ContentModeToggle; src/admin/pages/content/utils/contentEntryUtils.ts Live preview requires at least one published site snapshot and intentionally shows the current admin draft, not just the published entry version. Happy: toggle live and see template. Error: missing template empty state. Boundary: blank slug. Invalid: stale row id. Permission: content read/edit. Performance: canvas load acceptable. Mobile: mode toggle visible. CONTENT-005 draft-through-template Playwright regression passed 2026-06-22; missing-template and mobile live-canvas edges remain pending 0 None CONTENT-005 promoted through a content workspace regression in `content.e2e.ts`: log in fresh, publish the site snapshot required by LiveCanvas, create and save a post draft, make unsaved title/body edits, switch to Live mode, and verify the live iframe renders the draft title through the seeded entry-template heading, renders the draft-only body, and exposes the inline live-preview body editor. Verification: focused `bun run test:e2e -- --project=e2e tests/e2e/content.e2e.ts -g "CONTENT-005"` passed 2/2 including setup; full `bun run test:e2e -- --project=e2e tests/e2e/content.e2e.ts` passed 6/6 including setup; TSV 14-column guard, diff whitespace guard, and targeted stale-CONTENT search passed; `bun run lint` passed; `bun run build` passed; `bun test` passed 5496/5496. Run log: docs/e2e/runs/2026-06-22-content005-live-preview-template.md. 2026-06-22 +CONTENT-005 Content live canvas and template mode As a content author, I want to preview an entry inside its site template so I can see how it will look. Content mode toggles write/live; LiveCanvas renders entry via selected template/public path, mirrors active live path to toolbar, and uses current draft body/media/SEO state. No template configured; route base missing; public path with blank slug; draft changes vs live published. Template config/row data validated; content mode is client-side display only; public path derives from routeBase+slug. src/admin/pages/content/components/LiveCanvas; src/admin/pages/content/components/ContentModeToggle; src/admin/pages/content/utils/contentEntryUtils.ts Live preview requires at least one published site snapshot and intentionally shows the current admin draft, not just the published entry version. Happy: toggle live and see template. Error: missing template empty state. Boundary: blank slug. Invalid: stale row id. Permission: content read/edit. Performance: canvas load acceptable. Mobile: mode toggle visible. CONTENT-005 draft-through-template Playwright regression passed 2026-07-11 with an explicitly authored template; missing-template and mobile live-canvas edges remain pending 0 None CONTENT-005 now owns its precondition: it authors and publishes a low-priority Posts template containing a bound H1 and content outlet, creates/saves a post, makes unsaved title/body edits, switches to Live mode, and verifies those draft overrides in the iframe. Focused clean-DB verification passed on 2026-07-11 together with BUILDER-008 and the public form flows. 2026-07-11 CONTENT-006 Collection settings and fields As a content manager, I want to create/edit/delete collections and fields so content types match my site model. Collection create/settings dialogs update data tables/fields/primary field/route base through step-up-gated table APIs; system built-ins are protected. Deleting non-empty collection; built-in field rename/delete; field id collision; invalid route base; step-up cancel. DataTable schemas validate field types/options/required; table system flag controls allowed changes; content.manage/data.custom.tables.manage required. src/admin/pages/content/components/ContentCollectionCreateDialog/ContentCollectionCreateDialog.tsx; src/admin/pages/content/components/ContentCollectionSettingsDialog/ContentCollectionSettingsDialog.tsx; server/handlers/cms/data/tables.ts; src/core/data/systemTableGuard.ts PostType built-ins are title, slug, body, featuredMedia, SEO fields; arbitrary custom field editing is shared with the Data table inspector. Happy: create collection and add field. Error: delete non-empty blocked. Boundary: max/min field options. Invalid: duplicate field id. Permission: content manager only. Performance: field dialog responsive. Mobile: settings dialog scroll. CONTENT-006 built-in collection field Playwright regression passed 2026-06-22; custom field inspector and destructive schema edge E2E remains pending 0 None CONTENT-006 promoted through a focused content workspace regression in `content.e2e.ts`: create a custom collection through the New collection dialog, use the step-up prompt, disable Featured media and SEO fields, create a Product entry, verify Title and Body are present while Featured media and SEO title are absent, open Collection settings from the collection context menu, re-enable Featured media through step-up, and verify the settings panel reflects the updated field schema while SEO remains absent. Verification: TSV integrity guard passed; diff whitespace guard passed; targeted stale-CONTENT search returned no matches; focused `bun run test:e2e -- --project=e2e tests/e2e/content.e2e.ts -g "CONTENT-006"` passed 2/2 including setup; full `bun run test:e2e -- --project=e2e tests/e2e/content.e2e.ts` passed 4/4 including setup; `bun run lint` passed; `bun run build` passed; `bun test` passed 5496/5496. Run log: docs/e2e/runs/2026-06-22-content006-collection-fields.md. 2026-06-22 CONTENT-007 Content AI agent panel As a content author, I want an AI assistant in the content workspace so I can draft or transform content. ContentAgentMount uses shared AI agent store with content scope, conversation APIs, content snapshot/bridge tools, and configured defaults. No provider/default configured; tool write permission missing; conversation deleted; streaming abort. AI chat requires ai.chat; write tools require ai.tools.write; content snapshot schema/tool gates apply. src/admin/pages/content/agent; server/ai/handlers/chat.ts; server/ai/tools/content Default E2E data intentionally has no credentials, so no-provider guidance is the stable browser baseline; local/real AI provider credential is required for full chat/tool E2E. Happy: no-provider guidance or successful chat with configured provider. Error: provider failure. Boundary: empty prompt. Invalid: mismatched scope conversation. Permission: chat-only cannot write. Performance: stream events render. Mobile: panel usable. CONTENT-007 no-provider setup guidance Playwright regression passed 2026-06-22; provider-backed chat/tool flows remain pending 0 None CONTENT-007 promoted through a content workspace regression in `content.e2e.ts`: open the Content workspace, open the AI assistant rail panel, verify the AI Assistant complementary panel renders the Connect an AI provider setup alert, verify the Open AI settings affordance is present, and verify the message composer/send controls are disabled until credentials/default model exist. An initial test-only locator failure expected the setup title to be a heading, but Playwright's accessible snapshot showed the product rendered the guidance correctly as text inside `role="alert"`; the assertion was corrected to match the actual accessible structure. Verification: focused `bun run test:e2e -- --project=e2e tests/e2e/content.e2e.ts -g "CONTENT-007"` passed 2/2 including setup; full `bun run test:e2e -- --project=e2e tests/e2e/content.e2e.ts` passed 7/7 including setup. TSV 14-column guard, diff whitespace guard, and targeted stale-CONTENT search passed; `bun run lint` passed; `bun run build` passed; `bun test` passed 5496/5496. Run log: docs/e2e/runs/2026-06-22-content007-ai-no-provider.md. 2026-06-22 DATA-001 Data table management As a data manager, I want to create, view, edit, and delete custom/system tables so structured data can be modelled. DataPage lists tables, NewTableDialog creates custom table, inspector updates table schema/settings, delete requires confirmation/step-up and is blocked for system/non-empty as implemented. System tables locked from rename/delete; non-empty table delete warning; duplicate slug/id; selected table deleted after import. Table APIs require data custom/system read/manage caps; table input schema validates labels/slug/kind/fields; systemTableGuard protects locked tables. src/admin/pages/data/DataPage.tsx; src/admin/pages/data/components/NewTableDialog; server/handlers/cms/data/tables.ts; src/core/data/systemTableGuard.ts Data workspace also accepts content caps for picker use. Happy: create/update/delete empty custom table. Error: non-empty delete blocked. Boundary: system table custom field update. Invalid: duplicate slug. Permission: reader cannot manage. Performance: table list refresh. Mobile: sidebar/canvas usable. CAP-004 data capability Playwright coverage passed 2026-06-22; broader schema edit/delete exploratory pending 0 None DEF-20260622-010 resolved: data.custom.tables.manage now controls New table/table schema affordances instead of content.create; custom-table readers no longer see table creation. DEF-20260622-011 resolved: schema-only data personas no longer fetch row content or show Forbidden row-load alerts. Verification: bun run test:e2e -- --project=e2e tests/e2e/capabilities.e2e.ts -g "data capability"; bun test src/__tests__/admin/data/dataContextMenus.test.tsx. Run log: docs/e2e/runs/2026-06-22-cap004-data-capabilities.md. 2026-06-22 @@ -95,7 +95,7 @@ AI-001 AI credentials, provider models, and tests As an admin, I want to manage AI-002 AI defaults per scope As an admin, I want default credential/model per scope so site/content/data/plugin AI surfaces know what to use, and I can unset a default when a scope should stop using that credential. Defaults tab loads credentials+defaults, renders one row per site/content/data/plugin scope, opens the shared ModelPicker for each row, saves a selected credential/model with PUT /defaults/:scope, shows row-local Saved. feedback, resolves the saved label after reload when the credential still exists, clears a saved row with DELETE /defaults/:scope, shows Cleared. feedback, and allows the formerly defaulted credential to be deleted. No credentials; deleted credential/default becomes stale; scope without default; save or clear failure; stale or fallback model list; saving a site/content default can affect no-provider assistant guidance in shared E2E data; defaulted credentials are blocked from deletion until the default is cleared or reassigned. Scope union is site/content/data/plugin; set body requires non-empty credentialId and modelId; clear path validates the same scope union; setting/clearing requires ai.providers.manage; listing requires ai.chat; ModelPicker fetches models per credential and caches loaded groups; saved defaults only resolve when the credential is still visible to the user. src/admin/pages/ai/tabs/DefaultsTab.tsx; src/admin/ai/ModelPicker/ModelPicker.tsx; src/admin/ai/api.ts; server/ai/handlers/defaults.ts; server/ai/defaults/store.ts; src/__tests__/ai/defaultsHandler.test.ts; tests/e2e/ai.e2e.ts Some scopes may exist before their final AI surface; deterministic browser coverage uses the Data scope and clears it afterward so Site/Content assistant no-provider tests remain isolated in the shared E2E database. Happy: create offline Ollama credential, choose Llama 4 for Data, save, reload, verify the Data model button resolves the saved credential/model label, clear the default, delete the credential, and verify Content no-provider guidance is unaffected. Error: save/clear API failure. Boundary: no credentials empty state. Invalid: unsupported scope or empty ids. Permission: provider manager only. Performance: picker loads fallback models without blocking the tab. Mobile: rows fit. AI-002 Data-scope default save/reload/clear/delete Playwright and server regressions passed 2026-06-22; site/content/plugin permutations, no-credential empty state, stale credential recovery, live model catalogue defaults, permission variants, and mobile layout remain future expansion 0 None DEF-20260622-015 resolved: AI defaults could be set but not cleared, so a defaulted credential became undeletable unless the user reassigned that scope to another credential. This also polluted adjacent E2E journeys after AI-002 saved a Data default. Fix: `DELETE /admin/api/ai/defaults/:scope`, `clearDefault`, row-level Clear action, and post-clear credential deletion coverage. AI-002 promoted in `ai.e2e.ts`: the test opens `/admin/ai`, creates a unique Ollama base-URL credential pointed at `http://127.0.0.1:1`, opens Defaults, chooses fallback `Llama 4` for Data, saves, observes `Saved.`, reloads, verifies ` · Llama 4`, clears the Data default, observes `Cleared.`, deletes the credential, and cross-regresses CONTENT-007. Initial focused execution also exposed a test-only `role=status` accessible-name locator issue; no product defect was recorded for that locator. Verification: `bun test src/__tests__/ai/defaultsHandler.test.ts`; focused `bun run test:e2e -- --project=e2e tests/e2e/ai.e2e.ts -g "AI-002"`; cross-regression `bun run test:e2e -- --project=e2e tests/e2e/ai.e2e.ts tests/e2e/content.e2e.ts -g "AI-002|CONTENT-007"`. Run log: docs/e2e/runs/2026-06-22-ai002-defaults.md. 2026-06-22 AI-003 AI conversations As an AI user, I want conversations saved per scope so chat history can continue across sessions. Conversation APIs create/list/detail/delete/update title and persist messages for user/scope with credential/model chosen at creation; the Site assistant history popover lists saved chats, starts a fresh chat, reloads a saved chat, and deletes a chat. Conversation belongs to another user; credential deleted; empty title; deleting active conversation; multiple default-titled chats; empty history; deleted conversation should disappear from list and reset the active panel. Request schemas validate scope/title/default ids; ai.chat required for conversations; ownership enforced; list requires a valid scope query; browser history actions operate on the current scope. server/ai/handlers/conversations.ts; server/ai/conversations/store.ts; src/admin/ai/api.ts; src/admin/pages/site/agent/agentSlice.ts; src/admin/pages/site/panels/AgentPanel/ConversationHistory.tsx; tests/e2e/ai.e2e.ts; src/__tests__/ai/conversationRoundtrip.test.ts Conversation creation may fail if no default/credential is configured; the browser UI creates default-titled conversations and does not expose title editing yet. Happy: create provider-backed Site chat, start a new chat, load the saved conversation from history, verify user/assistant messages rehydrate, delete the active conversation, and verify the empty-history state. Error: another user's id 404. Boundary: empty history and default title. Invalid: bad scope. Permission: ai.chat. Performance: list loads on popover open. Mobile: history panel usable. AI-003 Site assistant conversation history load/new/delete Playwright coverage passed 2026-06-22; API title update, cross-user ownership denial, stale credential recovery, multi-conversation disambiguation, content/data/plugin scopes, and mobile layout remain future expansion 0 None DEF-20260622-011 resolved: site/content AI assistant entry points were gated by generic editability or rendered unconditionally instead of `ai.chat`; site readers with `ai.chat` can now open the assistant, and users without `ai.chat` do not see assistant controls. AI-003 promoted in `ai.e2e.ts`: a fixture-backed Ollama chat is created in the Site assistant, "New chat" clears the active panel, Conversation history reloads the saved user/assistant messages, deleting the active default-titled chat resets the panel, and the history popover shows `No chats yet.` Verification: focused `bun run test:e2e -- --project=e2e tests/e2e/ai.e2e.ts -g "AI-003"` and full `bun run test:e2e -- --project=e2e tests/e2e/ai.e2e.ts`. Run log: docs/e2e/runs/2026-06-22-ai003-conversations.md. 2026-06-22 AI-004 AI chat streaming and tool loop As an AI user, I want scoped chat to stream model output and optionally use tools so I can get assistance in the editor/content/data/plugin context. POST /ai/chat/:scope verifies conversation/scope/credential, builds system prompt/snapshot/tools, filters write tools by ai.tools.write, persists user/assistant/tool messages, flushes text-only assistant output before terminal usage persistence, and streams NDJSON events. No credential/default; scope mismatch; provider SSE failure; abort; tool error; large snapshot/context tokens; provider emits text and usage without tool calls. Chat body requires conversationId and non-empty prompt; valid scopes site/content/data/plugin; Site snapshot schema validated for site; ai.chat required; write tools are exposed only with ai.tools.write; usage is attached to an existing assistant message row. server/ai/handlers/chat.ts; server/ai/runtime; server/ai/tools; src/admin/pages/site/agent; src/admin/pages/content/agent; tests/e2e/ai.e2e.ts; src/__tests__/ai/auditUsagePersistence.test.ts External live providers may be unavailable, so deterministic browser coverage uses a fixture-backed local Ollama-compatible HTTP/SSE server. Happy: configured local Ollama fixture streams text into the Site AI assistant. Error: no provider guidance. Boundary: text-only reply with terminal usage; long prompt/snapshot. Invalid: empty prompt. Permission: chat-only no write tools. Performance: stream incremental. Mobile: panel usable. AI-004 site assistant local-provider streaming passed 2026-06-22; browser write-tool/tool-loop event UI, abort/error SSE recovery, content/data/plugin provider-backed permutations, and mobile layout remain future expansion 0 None DEF-20260622-011 resolved: AI assistant UI access no longer follows editability; site/content assistant controls now require `ai.chat`, and Spotlight AI commands require `ai.chat`. DEF-20260622-016 resolved: text-only streamed replies emitted terminal usage before pending assistant text was flushed, so usage persistence had no assistant row to attach to and audit rollups stayed zero. Fix: flush pending assistant text before `recordUsage`. Verification: `bun test src/__tests__/ai/auditUsagePersistence.test.ts`; `bun run test:e2e -- --project=e2e tests/e2e/ai.e2e.ts -g "AI-004|AI-006"`. Run log: docs/e2e/runs/2026-06-22-ai004-ai006-chat-audit.md. 2026-06-22 -AI-005 AI browser tool-result bridge As the AI runtime, I want browser-executed tools to return results so model tool loops can continue safely. Tool bridge creates request ids, emits browser `toolRequest` events, the browser executor runs approved tools against editor state, POST /ai/tool-result correlates the result, the provider loop receives the tool result turn, timeout/abort are bounded, and the assistant can continue streaming after the tool. Lost browser tab; timeout; duplicate result; malformed bridgeId/requestId; tool throws; tool request before bridgeReady; read-only browser tool vs mutating write tool; provider asks for unknown tool. Tool-result schema validates bridgeId/requestId/result and optional post-tool snapshot; bridge requires an active request; tool exposure is filtered by granted permissions, and mutating site tools require ai.tools.write. server/ai/handlers/toolResult.ts; server/ai/runtime/transport.ts; server/ai/runtime/bridge.ts; src/admin/pages/site/agent/streamEvents.ts; src/admin/pages/site/agent/executor.ts; tests/e2e/ai.e2e.ts; src/__tests__/ai/bridgeTimeoutAbort.test.ts; src/__tests__/ai/toolLoop.test.ts Browser E2E uses a fixture-backed local Ollama-compatible server that emits a `read_document` tool call, waits for the browser result turn, then returns final assistant text; CAP-005 also parses a downgraded chat persona's provider request to verify mutating tools are not exposed without ai.tools.write. Happy: fake provider requests `read_document`, browser marks `Completed read_document`, provider receives a second request with `role:"tool"` and matching `tool_call_id`, and assistant streams final text. Error: timeout event. Boundary: duplicate result ignored/rejected. Invalid: unknown request id. Permission: write-tool exposure is filtered by ai.tools.write. Performance: timeout bounded. Mobile: N/A. AI-005 read-only browser tool-result bridge Playwright coverage passed 2026-06-22; CAP-005 request-level write-tool filtering passed 2026-06-23; mutating write-tool success E2E, timeout/abort UX, duplicate-result rejection, malformed bridge ids, permission-denied execution defense, and unknown-tool provider requests remain future expansion 0 None AI-005 promoted in `ai.e2e.ts`: a fake Ollama server emits a first-turn `read_document` tool call, the Site assistant shows `Completed read_document`, the fake provider receives the browser tool result in the second chat request with `tool_call_id:"call_read_document"`, and the assistant renders `E2E bridge reply.` CAP-005 adds a downgraded `ai.chat` persona request-level filter check proving mutating tool definitions are omitted without `ai.tools.write`. Verification: focused `bun run test:e2e -- --project=e2e tests/e2e/ai.e2e.ts -g "AI-005"`, full `bun run test:e2e -- --project=e2e tests/e2e/ai.e2e.ts`, and focused `bun run test:e2e -- --project=e2e tests/e2e/ai.e2e.ts -g "chat-only site assistant request omits mutating write tools"`. Run logs: docs/e2e/runs/2026-06-22-ai005-tool-bridge.md; docs/e2e/runs/2026-06-23-cap005-ai-write-tool-filtering.md. 2026-06-23 +AI-005 AI browser tool-result bridge As the AI runtime, I want browser-executed tools to return results so model tool loops can continue safely. Tool bridge creates request ids, emits browser `toolRequest` events, the browser executor runs approved tools against editor state, POST /ai/tool-result correlates the result, the provider loop receives the tool result turn, timeout/abort are bounded, and the assistant can continue streaming after the tool. Lost browser tab; timeout; duplicate result; malformed bridgeId/requestId; tool throws; tool request before bridgeReady; read-only browser tool vs mutating write tool; provider asks for unknown tool. Tool-result schema validates bridgeId/requestId/result and optional post-tool snapshot; bridge requires an active request; tool exposure is filtered by granted permissions, and mutating site tools require ai.tools.write. server/ai/handlers/toolResult.ts; server/ai/runtime/transport.ts; server/ai/runtime/bridge.ts; src/admin/pages/site/agent/streamEvents.ts; src/admin/pages/site/agent/executor.ts; tests/e2e/ai.e2e.ts; src/__tests__/ai/bridgeTimeoutAbort.test.ts; src/__tests__/ai/toolLoop.test.ts Browser E2E uses a fixture-backed local Ollama-compatible server that emits a `site_read_document` tool call, waits for the browser result turn, then returns final assistant text; CAP-005 also parses a downgraded chat persona's provider request to verify mutating tools are not exposed without ai.tools.write. Happy: fake provider requests `site_read_document`, browser marks `Completed Reading document`, provider receives a second request with `role:"tool"` and matching `tool_call_id`, and assistant streams final text. Error: timeout event. Boundary: duplicate result ignored/rejected. Invalid: unknown request id. Permission: write-tool exposure is filtered by ai.tools.write. Performance: timeout bounded. Mobile: N/A. AI-005 read-only browser tool-result bridge Playwright coverage passed 2026-06-22; CAP-005 request-level write-tool filtering passed 2026-06-23; mutating write-tool success E2E, timeout/abort UX, duplicate-result rejection, malformed bridge ids, permission-denied execution defense, and unknown-tool provider requests remain future expansion 0 None AI-005 promoted in `ai.e2e.ts`: a fake Ollama server emits a first-turn `site_read_document` tool call, the Site assistant shows `Completed Reading document`, the fake provider receives the browser tool result in the second chat request with `tool_call_id:"call_site_read_document"`, and the assistant renders `E2E bridge reply.` CAP-005 adds a downgraded `ai.chat` persona request-level filter check proving mutating tool definitions are omitted without `ai.tools.write`. Verification: focused `bun run test:e2e -- --project=e2e tests/e2e/ai.e2e.ts -g "AI-005"`, full `bun run test:e2e -- --project=e2e tests/e2e/ai.e2e.ts`, and focused `bun run test:e2e -- --project=e2e tests/e2e/ai.e2e.ts -g "chat-only site assistant request omits mutating write tools"`. Run logs: docs/e2e/runs/2026-06-22-ai005-tool-bridge.md; docs/e2e/runs/2026-06-23-cap005-ai-write-tool-filtering.md. 2026-06-23 AI-006 AI audit and usage rollups As an AI admin/auditor, I want usage rollups by user/scope/model/day so cost and activity can be reviewed. GET /ai/audit?since&tz returns totals, byUser, byScope, byModel, byDay; AI Audit tab and dashboard widget render ranges and local timezone day buckets from persisted `ai_messages` usage rows. Invalid since falls back 30 days; invalid timezone falls back; zero usage; deleted credential/model labels; text-only replies without tool calls; usage arrives before final `done`. ai.audit.read required; since parsed ISO; timeZone resolved by server/time.ts; cost estimates use pricing table; audit rollups depend on chat persistence recording usage on the assistant message for each turn. server/ai/handlers/audit.ts; server/ai/audit/store.ts; src/admin/pages/ai/tabs/AuditTab.tsx; src/admin/pages/dashboard/widgets/AiUsageWidget.tsx; server/ai/runtime/runner.ts; tests/e2e/ai.e2e.ts; src/__tests__/ai/auditUsagePersistence.test.ts Usage rows are created by chat persistence; deterministic browser coverage uses the Site assistant to create real usage instead of direct DB seeding. Happy: local Ollama fixture streams a reply with prompt/completion usage and the Audit tab renders model, site scope, token totals, and daily spend. Error: audit API failure. Boundary: all-time range and text-only usage. Invalid: bad since/tz. Permission: auditor only. Performance: indexed rollups. Mobile: audit tables scroll/read. AI-006 Audit tab usage rollup browser path and server text-only usage regression passed 2026-06-22; dashboard widget, range switching, timezone/user permission variants, empty-state mobile layout, and deleted-label display remain future expansion 0 None DEF-20260622-016 resolved: text-only streamed replies did not persist usage because the runner handled terminal usage before flushing pending assistant text, leaving `lastAssistantMessageId` null. Fix: `runChat` flushes pending assistant text before recording usage. Browser coverage now creates a real Site chat through a fake Ollama server and verifies the Audit tab shows `e2e-model`, `site`, prompt tokens 123, completion tokens 45, and daily spend. Verification: `bun test src/__tests__/ai/auditUsagePersistence.test.ts`; `bun run test:e2e -- --project=e2e tests/e2e/ai.e2e.ts -g "AI-004|AI-006"`. Run log: docs/e2e/runs/2026-06-22-ai004-ai006-chat-audit.md. 2026-06-22 AI-007 Provider REST drivers and pricing As an operator, I want AI providers called without SDK lock-in so self-hosted installs can use supported REST APIs. Drivers for Anthropic/OpenAI/OpenRouter/Ollama map messages/tools to direct provider REST/SSE, share the provider-agnostic tool loop, normalize usage/pricing/context metadata, pass OpenRouter native cost through, and avoid banned SDKs. Provider rate limit; malformed SSE; malformed model catalogue; unsupported tool schema; unknown pricing catalogue entry; cache token accounting; OpenRouter native cost vs priced Anthropic/OpenAI/Ollama usage; missing or wrong auth mode. TypeBox tool schemas are passed as JSON Schema; external SSE/catalogue bodies are TypeBox-validated; no provider SDK imports; driver isolation architecture tests enforce; OpenRouter listModels maps pricing/context/capabilities from the provider catalogue. server/ai/drivers; server/ai/drivers/responses-shared.ts; server/ai/drivers/http/toolLoop.ts; server/ai/pricing; src/__tests__/ai/toolLoop.test.ts; src/__tests__/ai/anthropicMapping.test.ts; src/__tests__/ai/ollamaMapping.test.ts; src/__tests__/ai/responsesMapping.test.ts; src/__tests__/ai/pricing.test.ts; src/__tests__/architecture/ai-driver-isolation.test.ts This is user-facing through AI reliability, though mostly server/API; deterministic coverage uses mocked fetch because live provider credentials/network are unavailable in local CI. Happy: mocked Anthropic/OpenAI/Ollama/OpenRouter drivers complete streaming/tool-loop or text turns; OpenRouter posts to `/api/v1/responses`, sends bearer auth, omits OpenAI prompt-cache routing, passes `usage.cost` through, and maps model catalogue pricing/context/capabilities. Error: provider error surfaces. Boundary: unknown pricing returns zero or omitted pricing. Invalid: malformed SSE/catalogue bodies are ignored or rejected at the boundary. Permission: upstream chat caps. Performance: streaming does not buffer whole response. Mobile: N/A. AI-007 direct driver/pricing regressions passed 2026-06-22; live provider-network tests, malformed provider SSE/catalogue bodies, rate-limit UX, unknown-price catalogue misses, and full OpenRouter tool-loop browser permutations remain future expansion 0 None AI-007 promoted in `responsesMapping.test.ts`: direct OpenRouter stream coverage verifies `https://openrouter.ai/api/v1/responses`, bearer auth, no OpenAI `prompt_cache_key`, text streaming, token usage, and native `costUsd`; OpenRouter listModels coverage verifies pricing, contextWindow, vision/tool capabilities, and labels from the validated catalogue. Existing mapping/pricing tests cover Anthropic, OpenAI, Ollama, shared Responses translation, OpenRouter native cost parsing, and provider-SDK isolation gates. Verification: `bun test src/__tests__/ai/responsesMapping.test.ts`. Run log: docs/e2e/runs/2026-06-22-ai007-drivers.md. 2026-06-22 USERS-001 User management As an owner/admin, I want to list, create, edit, suspend, reset, activate, and delete users so admin access can be managed. Users tab loads users/roles, creates non-owner users, updates email/name/password/role/status, suspends and reactivates users through row actions, soft-deletes users, refreshes table state, blocks suspended users from login, and rejects login/session access for soft-deleted users. Cannot create owner; deleting current owner/self; duplicate email; suspended user login blocked; password autofill; fresh step-up window may allow sequential sensitive actions without reopening the prompt; already-authenticated deleted user should return to login on next admin request. User create requires email,password,roleId and rejects owner role; patch partial schema; passwords require at least 12 characters; users.manage required; sensitive mutations run through step-up; audit events recorded; user lookup excludes suspended/deleted users from authenticated access as implemented. server/handlers/cms/users.ts; server/repositories/users.ts; src/admin/pages/users/tabs/UsersTab.tsx; src/admin/pages/users/components/UserDialog.tsx; tests/e2e/users.e2e.ts Owner lifecycle E2E creates disposable local test users only; the lifecycle regression uses the built-in Admin role so login assertions prove admin access changes, while Member creation remains covered by the create-only path. Happy: create an Admin non-owner user, edit email/display name, suspend, activate, reset password, sign in with the reset password, delete, and verify table removal. Error: duplicate email remains lower-level/server-covered. Boundary: suspended user cannot log in, deleted user's active session is forced back to Admin Login on the next admin request, and deleted user cannot log in again. Invalid: short password blocked. Permission: users.manage only and step-up gates sensitive mutations. Performance: table refreshes after each mutation. Mobile: user create step-up and Users/roles tables remain usable through related coverage. USERS-001 create/edit/suspend/activate/reset/delete Playwright lifecycle regression passed 2026-06-23; CAP-003 user-create step-up and mobile step-up regressions passed; duplicate email and owner/self guard edges remain lower-level/server coverage 0 None users.e2e.ts now creates a non-owner Admin user, verifies the row is Active/Admin, edits email and display name through Edit User, suspends the row and verifies suspended login receives the Admin Login error, activates the row, resets password, signs in as the reset user, deletes the user from the owner session, verifies the already-authenticated deleted user returns to Admin Login on the next admin route, and verifies deleted credentials are rejected. Existing users.e2e.ts coverage creates a Member user and verifies create-user step-up cancel/wrong/correct paths plus mobile step-up containment. Verification: full bun run test:e2e -- --project=e2e tests/e2e/users.e2e.ts passed 12/12 including setup; focused USERS-001 run passed 2/2 including setup; bun run lint passed; bun run build passed; bun test passed 5701/5701. Run logs: docs/e2e/runs/2026-06-23-users001-user-lifecycle.md; docs/e2e/runs/2026-06-22-cap003-user-create-step-up.md. 2026-06-23 @@ -127,13 +127,13 @@ BUILDER-001 Insert common visual modules As a site editor, I want to add common BUILDER-002 Select canvas nodes and edit properties As a site editor, I want selection to drive the Properties panel so changing a field updates the intended node and not a previously selected element. After inserting Text and setting an initial headline, inserting Image moves the Properties panel to the image src control; selecting the Text row from Layers restores text controls, editing text updates the canvas, and the old headline disappears. Selection lost after insertion; wrong tree row selected; property panel still bound to previous node; duplicate layer names; hidden/locked nodes; stale iframe text after update. Layer selection updates the editor selection store; property controls are rendered for the selected module schema; text prop edits mutate the active tree and rerender the canvas iframe. tests/e2e/visual-builder.e2e.ts; tests/e2e/helpers/editor.ts; src/admin/pages/site/panels/DomPanel; src/admin/pages/site/store/slices/selectionSlice.ts; src/admin/pages/site/property-controls/PropertyControlRenderer.tsx; src/modules/base/text The regression uses the first Text layer name and one Image insertion to prove selection moves; multi-select and locked-region behavior are covered by other builder/editor tests. Happy: select Text from Layers and edit text. Error: missing property control fails visibly. Boundary: two modules with selection handoff. Invalid: schema-invalid property values rejected by controls/store. Permission: edit capability required. Performance: canvas text updates promptly. Mobile: selection on narrow editor remains future exploratory coverage. Focused page-management and visual-builder Playwright regression passed 2026-06-23; canonical spreadsheet row added after matrix gap discovery 0 None Verification: bun run test:e2e -- --project=e2e tests/e2e/page-management.e2e.ts tests/e2e/visual-builder.e2e.ts passed 21/21 including setup. The BUILDER-002 scenario inserts Text, sets Selectable headline, inserts Image and sees the src property control, selects Text in the Layers tree, sets Edited headline, verifies edited text is visible, and verifies the old headline is gone. Run log: docs/e2e/runs/2026-06-23-page-builder-management.md. 2026-06-23 BUILDER-003 DOM panel structure editing As a site editor, I want the Layers/DOM panel to reflect and control page structure so I can reorder nodes and manage generated slot content safely. The DOM panel can reorder sibling Text layers and the canvas order matches before and after save/reload; generated slot-instance rows are locked against rename/duplicate/cut/delete but still allow inserting slot-fill content; tablet-width notch insertion still creates visible Text layers. Impossible drop targets; drag order mismatch after save/reload; locked slot rows exposing destructive actions; slot fill insertion blocked; duplicate layer names; scroll or viewport affecting insert controls. DOM-panel drag/drop routes through tree mutations; locked slot-instance rows suppress destructive menu items while preserving allowed insert action; saved page tree validates before reload assertions. tests/e2e/visual-builder.e2e.ts; src/admin/pages/site/panels/DomPanel; src/admin/pages/site/panels/DomPanel/useDomPanelDnd.ts; src/core/page-tree/mutations.ts; src/core/visualComponents/slotSync.ts; src/modules/base/slotInstance; tests/e2e/helpers/editor.ts This row combines DOM reordering, slot lock affordances, and tablet-width insertion because the matrix row is DOM-panel/structure oriented; direct canvas drag has its own BUILDER-004 row. Happy: reorder Text rows and verify canvas order persists after save/reload. Error: failed drag should not silently reorder. Boundary: three sibling Text nodes. Invalid: locked generated slot row hides destructive actions. Permission: structure edit required. Performance: drag and reload assertions complete within E2E timeout. Mobile: tablet viewport insertion at 768px is covered; phone layout remains future coverage. Focused page-management and visual-builder Playwright regression passed 2026-06-23; canonical spreadsheet row added after matrix gap discovery 0 None Verification: bun run test:e2e -- --project=e2e tests/e2e/page-management.e2e.ts tests/e2e/visual-builder.e2e.ts passed 21/21 including setup. BUILDER-003 evidence includes DOM-panel reorder from [alpha, beta, gamma] to [gamma, alpha, beta] with save/reload persistence, locked slot-instance context menu checks that Insert module here is present while Rename/Duplicate/Cut/Delete are absent, slot-fill Text insertion, and a 768x900 tablet insertion check with two visible Text rows. Run log: docs/e2e/runs/2026-06-23-page-builder-management.md. 2026-06-23 BUILDER-004 Canvas drag reordering As a site editor, I want to drag a selected canvas layer to reorder content directly where I can see it so layout changes are spatial and immediate. Selecting the middle Text node exposes the Drag selected layers handle, dragging it near the last node shows an after-position drop indicator, mouse release reorders the canvas text from alpha, beta, gamma to alpha, gamma, beta. Handle not visible; iframe coordinate mismatch; no measurable drop target; drop indicator missing; scroll/zoom offset; drag cancels; final tree/canvas order diverges. Canvas drag uses measured element geometry and editor tree mutation paths; the browser regression asserts the visible drop indicator and final iframe text order. tests/e2e/visual-builder.e2e.ts; src/admin/pages/site/canvas/useCanvasReorderDrag.ts; src/admin/pages/site/canvas/canvasDnd.ts; src/admin/pages/site/canvas/iframeEventCoordinates.ts; src/core/page-tree/mutations.ts The automated case covers one desktop drag after selecting Text; persistence after canvas drag, multi-select drag, zoomed canvas, and mobile drag remain future coverage or lower-level tests. Happy: drag selected middle layer after last layer and verify order. Error: missing drop indicator or failed reorder. Boundary: three visible sibling Text nodes. Invalid: locked/unsupported targets should reject drops. Permission: structure edit required. Performance: drag response within E2E timeout. Mobile: direct canvas drag on touch/narrow viewports remains future coverage. Focused page-management and visual-builder Playwright regression passed 2026-06-23; canonical spreadsheet row added after matrix gap discovery 0 None Verification: bun run test:e2e -- --project=e2e tests/e2e/page-management.e2e.ts tests/e2e/visual-builder.e2e.ts passed 21/21 including setup. The BUILDER-004 scenario creates three Text nodes, selects beta in the canvas, drags the Drag selected layers handle toward gamma, verifies a data-position=after indicator, releases, and verifies canvas text order alpha, gamma, beta. Run log: docs/e2e/runs/2026-06-23-page-builder-management.md. 2026-06-23 -BUILDER-005 Undo and redo visual edits As a site editor, I want visible undo and redo controls plus keyboard shortcuts to move page state backward and forward so I can recover from recent visual edits. A loaded page with no local history renders both notch controls unavailable. Inserting Text creates one undoable visual edit and leaves Redo unavailable. Ctrl/Cmd+Z removes the Text layer and enables Redo. Ctrl/Cmd+Shift+Z restores it. Clicking Undo removes it again. Inserting a different module after that Undo clears Redo and starts a new history branch. Saving and reloading persists the new module but resets transient Undo/Redo availability. Undo disabled despite history; enabled control still marked aria-disabled; redo available after a fresh insert; shortcut handler firing inside inputs; redo branch not cleared by a new mutation; saved content lost after reload; stale selected node after undo; property edit bursts not coalesced. Undo/redo buttons operate on the editor history store and shared Button primitive; unavailable state is `aria-disabled=true`; enabled state omits aria-disabled; keyboard shortcuts come from `editor.undo`/`editor.redo` registry entries and are ignored inside editable targets; visual assertions use layer rows after each action. tests/e2e/visual-builder.e2e.ts; src/admin/pages/site/canvas/UndoRedoButtons.tsx; src/admin/pages/site/store/slices/site/undoRedoActions.ts; src/admin/pages/site/store/slices/site/helpers.ts; src/admin/spotlight/keybindings.ts; src/__tests__/editor-store/undo-redo.test.ts The browser regression covers one loaded page, one undo/redo keyboard round trip, one button undo, one branch-clearing edit, and one save/reload reset. Store tests cover deeper coalescing, max-history, no-op, runtime, VC, and dirty-tracking cases. Happy: insert Text, shortcut undo/redo, button undo, branch with Container, save/reload. Error: controls fail to mutate tree or expose wrong availability. Boundary: clean loaded page and reload boundary. Invalid: empty-history controls stay unavailable; no-op mutations covered lower-level. Permission: edit access required. Performance: tree updates promptly. Mobile: phone-width notch history reachability remains future coverage. SITE-009/BUILDER-005 expanded Playwright regression passed 2026-06-23; canonical row refreshed from implementation; no product defects found in this slice 0 None Verification: focused `bun run test:e2e -- --project=e2e tests/e2e/visual-builder.e2e.ts -g "BUILDER-005"` passed 2/2 including setup. The scenario now creates/saves/reloads a disposable page, asserts Undo/Redo are unavailable on the loaded draft, inserts Text, undoes with Ctrl/Cmd+Z, redoes with Ctrl/Cmd+Shift+Z, undoes with the notch button, inserts Container to clear Redo, saves/reloads, and verifies Container persists while history controls reset. Run logs: docs/e2e/runs/2026-06-23-page-builder-management.md; docs/e2e/runs/2026-06-23-site009-undo-redo.md. 2026-06-23 +BUILDER-005 Undo and redo visual edits As a site editor, I want visible undo and redo controls plus keyboard shortcuts to move page state backward and forward so I can recover from recent visual edits. A loaded page with no local history renders both notch controls unavailable. Inserting Text creates one undoable visual edit and leaves Redo unavailable. Ctrl/Cmd+Z removes the Text layer and enables Redo. Ctrl/Cmd+Shift+Z restores it. Clicking Undo removes it again. Inserting a different module after that Undo clears Redo and starts a new history branch. Saving and reloading persists the new module but resets transient Undo/Redo availability. Undo disabled despite history; enabled control still marked aria-disabled; redo available after a fresh insert; shortcut handler firing inside inputs; redo branch not cleared by a new mutation; saved content lost after reload; stale selected node after undo; property edit bursts not coalesced. Undo/redo buttons operate on the editor history store and shared Button primitive; unavailable state is `aria-disabled=true`; enabled state omits aria-disabled; keyboard shortcuts come from `editor.undo`/`editor.redo` registry entries and are ignored inside editable targets; visual assertions use layer rows after each action. tests/e2e/visual-builder.e2e.ts; src/admin/pages/site/canvas/UndoRedoButtons.tsx; src/admin/pages/site/store/slices/site/undoRedoActions.ts; src/admin/pages/site/store/slices/site/helpers.ts; src/admin/spotlight/keybindings.ts; src/__tests__/editor-store/undo-redo.test.ts The browser regression covers one loaded page, one undo/redo keyboard round trip, one button undo, one branch-clearing edit, and one save/reload reset. Store tests cover deeper coalescing, max-history, no-op, runtime, VC, and dirty-tracking cases. Happy: insert Text, shortcut undo/redo, button undo, branch with Container, save/reload. Error: controls fail to mutate tree or expose wrong availability. Boundary: clean loaded page and reload boundary. Invalid: empty-history controls stay unavailable; no-op mutations covered lower-level. Permission: edit access required. Performance: tree updates promptly. Mobile: phone-width notch history reachability remains future coverage. SITE-009/BUILDER-005 focused Playwright and store regressions passed 2026-07-11; stale history selection defect fixed 0 None The release-gate run exposed that undo restored the site tree but left selection pointing at the removed Text node, so the next Container insertion silently no-op'd. PR #213 now prunes invalid selection after both undo and redo, with focused store regressions for reverted insertion/replayed deletion. The 2026-07-11 sequential Playwright run passed the full shortcut/button/branch/save-reload scenario. 2026-07-11 BUILDER-006 Apply spacing, color, and typography controls As a site editor, I want class-based style controls for typography, color, and spacing to preview immediately and publish as visitor CSS. A button can receive a reusable class, the Properties panel style search can add font size, background color, and padding controls, the desktop canvas reflects 24px font size, rgb(0, 170, 85) background, and 12px padding-top, those styles persist after save/reload, and the published visitor page has the same CSS and class. Invalid CSS values; token/category confusion; class chip lost after reload; canvas preview differs from publish; background color normalization; property rows not found by search; stale public CSS. Style property rows update generated class metadata; CSS is applied to the design canvas and emitted by the publisher; save/reload validates class/style state before public assertions. tests/e2e/visual-builder.e2e.ts; src/admin/pages/site/panels/PropertiesPanel/StyleSectionsEditor.tsx; src/admin/pages/site/panels/PropertiesPanel/ClassPicker.tsx; src/core/framework; src/core/publisher; server/publish/siteCssBundle.ts The regression uses one class on a button and three representative CSS properties; full style matrix, token pickers, invalid-value copy, and mobile authoring layout remain future coverage. Happy: apply typography/color/spacing controls, save/reload, publish, and verify CSS. Error: invalid style should show recoverable feedback. Boundary: hex input normalized to rgb. Invalid: malformed CSS values covered lower-level. Permission: style edit plus publish capabilities required. Performance: preview and publish assertions within E2E timeout. Mobile: visitor breakpoint styling covered by BUILDER-007; mobile authoring remains future coverage. Focused page-management and visual-builder Playwright regression passed 2026-06-23; canonical spreadsheet row added after matrix gap discovery 0 None Verification: bun run test:e2e -- --project=e2e tests/e2e/page-management.e2e.ts tests/e2e/visual-builder.e2e.ts passed 21/21 including setup. The BUILDER-006 scenario creates a classed button, sets font-size 24px, background-color #00aa55, and padding-top 12px, verifies those CSS values in the canvas, saves/reloads and verifies again, publishes, then verifies class and CSS on the visitor route. Run log: docs/e2e/runs/2026-06-23-page-builder-management.md. 2026-06-23 BUILDER-007 Breakpoint-specific style variants As a site editor, I want mobile and desktop breakpoint edits to stay scoped so responsive styles can differ without accidentally overwriting the default design. A classed button can receive 20px font size in Desktop editing context and 33px font size in Mobile editing context; the desktop and mobile design frames show their own values, publish emits the default desktop CSS, and a 360px visitor viewport receives the mobile CSS after reload. Accidental global change from breakpoint edit; wrong frame targeted; stale mobile CSS after publish; viewport reload mismatch; class chip lost; desktop style overridden by mobile style. Breakpoint context selection changes the active style target; generated CSS emits default and breakpoint-scoped rules; visitor checks run at default and 360px viewport widths. tests/e2e/visual-builder.e2e.ts; src/admin/pages/site/canvas/CanvasContextSelector.tsx; src/admin/pages/site/canvas/BreakpointFrame.tsx; src/admin/pages/site/store/slices/styleRule; src/core/framework; server/publish/siteCssBundle.ts The automated case covers class font-size differences for desktop and mobile; tablet-specific authored variants, complex layout controls, and mobile authoring ergonomics remain future coverage. Happy: set desktop/mobile font sizes and verify canvas plus public CSS. Error: publish missing breakpoint CSS fails visitor assertion. Boundary: visitor viewport 360x800. Invalid: unsupported breakpoint id rejected by context controls/store. Permission: style edit and publish required. Performance: context switch and visitor reload complete within E2E timeout. Mobile: primary visitor breakpoint path covered. Focused page-management and visual-builder Playwright regression passed 2026-06-23; canonical spreadsheet row added after matrix gap discovery 0 None Verification: bun run test:e2e -- --project=e2e tests/e2e/page-management.e2e.ts tests/e2e/visual-builder.e2e.ts passed 21/21 including setup. The BUILDER-007 scenario creates a responsive classed button, sets Desktop font-size to 20px, switches to Mobile and sets 33px, verifies both canvas frames independently, saves, publishes, verifies the visitor button at default width has 20px, then reloads at 360x800 and verifies 33px. Run log: docs/e2e/runs/2026-06-23-page-builder-management.md. 2026-06-23 CAP-002 Site edit mode capability boundaries As an administrator, I want content, style, and structure editor roles to be isolated so each persona can use only its intended editor controls. Custom content-only, style-only, and structure-only personas can enter the Site editor, perform their permitted edit class, save or reload as applicable, and are blocked from controls outside their capability set. Controls hidden for the wrong persona; enabled controls that fail only after click; accidental writes to structure/style/content; save/reload losing permitted edits; direct URL access with limited roles. Role capability sets are created through the Users/Roles UI and enforced by admin access checks, editor affordance gating, and server-side CMS persistence checks. tests/e2e/capabilities.e2e.ts; src/admin/access.ts; src/admin/pages/site; server/handlers/cms; server/repositories/roles.ts The browser regression uses disposable custom roles and verifies representative edit-mode boundaries rather than every possible module property. Happy: each persona completes its permitted edit class. Error: forbidden controls remain unavailable. Boundary: save and reload after permitted edit. Invalid: missing capability cannot mutate other edit class. Permission: custom role gates verified. Performance: editor ready within E2E timeout. Mobile: tablet editor coverage is tracked by RESP-001. Focused capability/responsive/spotlight Playwright regression passed 2026-06-23; canonical spreadsheet row added after matrix gap discovery 0 None Verification: bun run test:e2e -- --project=e2e tests/e2e/capabilities.e2e.ts tests/e2e/accessibility.e2e.ts tests/e2e/command-palette.e2e.ts passed with Playwright .last-run status passed and no failed tests. CAP-002 covers three site-edit personas and verifies cross-mode controls do not leak. Run log: docs/e2e/runs/2026-06-23-capability-responsive-spotlight.md. 2026-06-23 CAP-003 Sensitive action step-up gates As a security-conscious administrator, I want sensitive operations to require fresh password confirmation so stale sessions cannot publish, install plugins, import destructively, or perform secondary account changes. Publish and other sensitive actions open the step-up dialog when freshness is required; cancel or wrong password leaves the resource unchanged; correct password proceeds and the action completes; after a fresh step-up window expires, the next sensitive UI action re-prompts before mutation; at 390px the create-user step-up dialog remains viewport-contained with password, cancel, confirm, and inline error states usable; for MFA-enabled sessions, sensitive-action step-up requires both the password and current TOTP code. Side effects before confirmation; wrong-password mutation; wrong-MFA-code mutation; stale freshness after timeout; plugin install/uninstall bypass; destructive import bypass; secondary API actions bypassing browser UI; mobile dialog overflow, clipped controls, or hidden wrong-password feedback. Sensitive routes and UI actions require fresh step-up state in addition to capability checks; request bodies and responses use TypeBox validation and explicit error envelopes; MFA-enabled users must include a valid second factor for step-up. tests/e2e/capabilities.e2e.ts; tests/e2e/users.e2e.ts; tests/e2e/account.e2e.ts; tests/e2e/core-owner-lifecycle.e2e.ts; src/__tests__/server/stepUpSecondaryActions.test.ts; server/auth/stepUp.ts Promoted browser coverage spans publish, plugin install/uninstall, destructive import, desktop/mobile/MFA user creation, expired-window re-prompt, and secondary API gates. Happy: correct password allows sensitive action. Error: cancel, wrong password, and wrong MFA code do not mutate. Boundary: after successful step-up rotates the session, expiring that session's step_up_expires_at in disposable SQLite reopens the step-up dialog before the next create-user mutation. Invalid: bad password or TOTP returns inline failure. Permission: action still requires capability. Performance: dialog resolves within E2E timeout. Mobile: at 390x844, the create-user step-up dialog stays horizontally and vertically contained, required controls are visible, wrong-password feedback stays contained, and correct password creates the user. MFA: after MFA login, create-user step-up renders the second-factor field, keeps confirm disabled until it is filled, rejects a wrong code without creating the user, and creates the user with a valid TOTP. Passed 2026-06-23: focused expired-window, mobile, and MFA user-create step-up regressions, full users/account E2E regressions, existing plugin/import/user/publish browser coverage, and secondary API gates; CAP-003 browser coverage is now promoted to automated 0 None Verification: focused `bun run test:e2e -- --project=e2e tests/e2e/users.e2e.ts -g "step-up window expires"` passed 2/2 including setup; full `bun run test:e2e -- --project=e2e tests/e2e/users.e2e.ts` passed 11/11 including setup after the expired-window promotion; focused `bun run test:e2e -- --project=e2e tests/e2e/users.e2e.ts -g "user creation step-up stays usable"` passed 2/2 including setup before the promotion. Focused `bun run test:e2e -- --project=e2e tests/e2e/account.e2e.ts -g "requires MFA code for sensitive-action"` passed 2/2 including setup, and full `tests/e2e/account.e2e.ts` passed 23/23. Prior CAP-003 evidence includes destructive CMS bundle replace import and plugin JSON-manifest install/uninstall step-up checks in capabilities.e2e.ts, plus existing publish/user/secondary API coverage referenced by docs/e2e/feature-matrix.md. Run logs: docs/e2e/runs/2026-06-23-cap003-mobile-step-up.md; docs/e2e/runs/2026-06-23-cap003-mfa-step-up.md; docs/e2e/runs/2026-06-23-cap003-stale-step-up.md. 2026-06-23 CAP-004 Data and media capability splits As an administrator, I want data and media roles to expose only their permitted browse, mutate, import, export, upload, replace, and delete affordances so operators cannot overreach their role. Custom personas see and use only the data or media operations granted to them; read-only users can browse without mutate actions; data manager/exporter/importer personas expose only their matching controls; content row move controls require data.rows.move and allowed movers can move entries between collections; media writer can upload/edit metadata without replace/delete; media replacer can replace bytes without metadata/delete; and media deleter can trash/purge without upload/replace. Destructive actions exposed too broadly; read-only users seeing write menus; import/export mismatch; content row move implied by edit; media upload without write access; replace/delete implied by media.write; data rows visible to unauthorized users; mixed capabilities across workspaces. Role capabilities gate workspace access, menu/actions, upload/import/export/row-move/replace/delete controls, and server handlers; CMS responses are validated by the shared HTTP layer. tests/e2e/capabilities.e2e.ts; src/admin/pages/data; src/admin/pages/content/ContentPage.tsx; src/admin/pages/content/components/ContentExplorerPanel/ContentExplorerPanel.tsx; src/admin/pages/content/components/ContentSettingsPanel/ContentSettingsPanel.tsx; src/admin/pages/media; server/handlers/cms/data.ts; server/handlers/cms/media.ts; server/repositories/roles.ts The browser regression focuses representative data read/manage/import/export, content row-move, and media read/write/replace/delete affordance splits plus content row-move and media replace/delete happy paths. Happy: permitted data/media operation is visible and usable, and a row mover can move a content entry between collections. Error: forbidden affordance hidden. Boundary: read-only browse without write menus; edit-only content user has no move controls; media.write does not imply replace/delete. Invalid: ungranted import/export/row-move/replace/delete blocked by UI and server. Permission: custom personas verify role gates. Performance: workspaces load within E2E timeout. Mobile: responsive admin shell covered separately. Passed 2026-06-23: focused CAP-004 content row-move regression and full capabilities E2E passed; CAP-004 split coverage now includes data read/manage/import/export, content row move, and media read/write/replace/delete. 0 None Verification: focused `bun run test:e2e -- --project=e2e tests/e2e/capabilities.e2e.ts -g "content (editor without row-move|mover can move)"` passed 3/3 including setup, and full `bun run test:e2e -- --project=e2e tests/e2e/capabilities.e2e.ts` passed 25/25 including setup. DEF-20260623-003 found and fixed after RED showed an edit-only content persona could see Move to collection without data.rows.move; fix wires canMoveDataRow through ContentPage, ContentExplorerPanel, and ContentSettingsPanel. Prior CAP-004 evidence also covers media replace without metadata/delete controls and media trash/purge without upload/replace controls. Run logs: docs/e2e/runs/2026-06-23-capability-responsive-spotlight.md; docs/e2e/runs/2026-06-23-cap004-media-destructive-capabilities.md; docs/e2e/runs/2026-06-23-cap004-row-move-capabilities.md. 2026-06-23 -CAP-005 Plugin and AI capability splits As an administrator, I want plugin and AI surfaces to honor separate read, install, lifecycle, settings, schedule, pack, chat, provider, audit, and AI write-tool permissions so each role sees only its allowed operational surface. Plugin reader personas can view without install controls and can inspect schedules without schedule mutation controls; plugin installer personas can reach install/remove/pack re-sync flows without settings/lifecycle controls; plugin configurators can open Settings without install/lifecycle controls; plugin lifecycle operators can see lifecycle and schedule run/pause controls without settings/install/pack re-sync controls; AI chat personas can use the assistant rail; AI provider managers can access Providers/Defaults without Audit; AI auditors can access Audit without provider/default controls; and `ai.chat` personas without `ai.tools.write` send model requests that include read tools but omit mutating write tools. Settings leak to read-only role; install visible without install capability; lifecycle controls shown too broadly; schedule mutation controls shown without lifecycle capability; pack re-sync visible without install capability; AI provider defaults visible to chat-only or auditor role; AI audit visible to provider-manager role; mutating AI write tools exposed to `ai.chat` users without `ai.tools.write`. Capabilities are checked by admin access helpers, plugin UI gating, AI route handlers, AI tool-selection gates, and server-side permission checks against granted permissions. tests/e2e/capabilities.e2e.ts; tests/e2e/ai.e2e.ts; src/admin/pages/plugins; src/admin/ai; server/plugins; server/ai; server/repositories/roles.ts The promoted E2E covers plugin read/install/configure/lifecycle affordance split, installed-plugin schedule read vs lifecycle mutation controls, pack re-sync installer-only affordance, AI chat rail gating, AI provider/audit tab separation, and AI write-tool filtering for a downgraded `ai.chat` persona. Happy: allowed plugin or AI surface is reachable. Error: forbidden controls hidden. Boundary: read-only plugin browsing and schedule inspection, plugin configure/lifecycle/install/pack isolation, provider-manager/auditor tab isolation, and `ai.chat` without `ai.tools.write`. Invalid: install/settings/lifecycle/schedule mutation/pack re-sync without capability blocked; AI provider/audit tabs absent without matching capability; mutating AI tools absent without write capability. Permission: separate plugin and AI custom personas. Performance: plugin and AI pages render within E2E timeout. Mobile: broader responsive split remains future coverage. Passed 2026-06-23: focused CAP-005 AI write-tool filtering regression and full AI E2E suite passed after prior plugin schedule/pack and full capabilities E2E evidence; CAP-005 listed split coverage is now browser-automated 0 None Verification: focused `bun run test:e2e -- --project=e2e tests/e2e/ai.e2e.ts -g "chat-only site assistant request omits mutating write tools"` passed 2/2 including setup; full `bun run test:e2e -- --project=e2e tests/e2e/ai.e2e.ts` passed 7/7 including setup. The regression creates a role with temporary provider setup capability, creates a fake Ollama credential as that persona, downgrades the role to `View site` + `Use AI chat`, sends a Site assistant prompt, then parses the provider request to prove `read_document` and `render_snapshot` are present while `insertHtml`, `replaceNodeHtml`, `updateNodeProps`, `applyCss`, `write_code_asset`, `addPage`, and `deletePage` are absent. Prior CAP-005 evidence includes full `tests/e2e/capabilities.e2e.ts` passing 23/23, focused plugin schedule/pack split 5/5, focused AI provider/audit tab split 3/3, and focused plugin action split 5/5. Run logs: docs/e2e/runs/2026-06-23-capability-responsive-spotlight.md; docs/e2e/runs/2026-06-23-cap005-ai-provider-audit-capabilities.md; docs/e2e/runs/2026-06-23-cap005-plugin-action-capabilities.md; docs/e2e/runs/2026-06-23-cap005-plugin-schedule-pack-capabilities.md; docs/e2e/runs/2026-06-23-cap005-ai-write-tool-filtering.md. 2026-06-23 +CAP-005 Plugin and AI capability splits As an administrator, I want plugin and AI surfaces to honor separate read, install, lifecycle, settings, schedule, pack, chat, provider, audit, and AI write-tool permissions so each role sees only its allowed operational surface. Plugin reader personas can view without install controls and can inspect schedules without schedule mutation controls; plugin installer personas can reach install/remove/pack re-sync flows without settings/lifecycle controls; plugin configurators can open Settings without install/lifecycle controls; plugin lifecycle operators can see lifecycle and schedule run/pause controls without settings/install/pack re-sync controls; AI chat personas can use the assistant rail; AI provider managers can access Providers/Defaults without Audit; AI auditors can access Audit without provider/default controls; and `ai.chat` personas without `ai.tools.write` send model requests that include read tools but omit mutating write tools. Settings leak to read-only role; install visible without install capability; lifecycle controls shown too broadly; schedule mutation controls shown without lifecycle capability; pack re-sync visible without install capability; AI provider defaults visible to chat-only or auditor role; AI audit visible to provider-manager role; mutating AI write tools exposed to `ai.chat` users without `ai.tools.write`. Capabilities are checked by admin access helpers, plugin UI gating, AI route handlers, AI tool-selection gates, and server-side permission checks against granted permissions. tests/e2e/capabilities.e2e.ts; tests/e2e/ai.e2e.ts; src/admin/pages/plugins; src/admin/ai; server/plugins; server/ai; server/repositories/roles.ts The promoted E2E covers plugin read/install/configure/lifecycle affordance split, installed-plugin schedule read vs lifecycle mutation controls, pack re-sync installer-only affordance, AI chat rail gating, AI provider/audit tab separation, and AI write-tool filtering for a downgraded `ai.chat` persona. Happy: allowed plugin or AI surface is reachable. Error: forbidden controls hidden. Boundary: read-only plugin browsing and schedule inspection, plugin configure/lifecycle/install/pack isolation, provider-manager/auditor tab isolation, and `ai.chat` without `ai.tools.write`. Invalid: install/settings/lifecycle/schedule mutation/pack re-sync without capability blocked; AI provider/audit tabs absent without matching capability; mutating AI tools absent without write capability. Permission: separate plugin and AI custom personas. Performance: plugin and AI pages render within E2E timeout. Mobile: broader responsive split remains future coverage. Passed 2026-06-23: focused CAP-005 AI write-tool filtering regression and full AI E2E suite passed after prior plugin schedule/pack and full capabilities E2E evidence; CAP-005 listed split coverage is now browser-automated 0 None Verification: focused `bun run test:e2e -- --project=e2e tests/e2e/ai.e2e.ts -g "chat-only site assistant request omits mutating write tools"` passed 2/2 including setup; full `bun run test:e2e -- --project=e2e tests/e2e/ai.e2e.ts` passed 7/7 including setup. The regression creates a role with temporary provider setup capability, creates a fake Ollama credential as that persona, downgrades the role to `View site` + `Use AI chat`, sends a Site assistant prompt, then parses the provider request to prove `site_read_document` and `site_render_snapshot` are present while `site_insert_html`, `site_replace_node_html`, `site_update_node_props`, `site_apply_css`, `site_write_code_asset`, `site_add_page`, and `site_delete_page` are absent. Prior CAP-005 evidence includes full `tests/e2e/capabilities.e2e.ts` passing 23/23, focused plugin schedule/pack split 5/5, focused AI provider/audit tab split 3/3, and focused plugin action split 5/5. Run logs: docs/e2e/runs/2026-06-23-capability-responsive-spotlight.md; docs/e2e/runs/2026-06-23-cap005-ai-provider-audit-capabilities.md; docs/e2e/runs/2026-06-23-cap005-plugin-action-capabilities.md; docs/e2e/runs/2026-06-23-cap005-plugin-schedule-pack-capabilities.md; docs/e2e/runs/2026-06-23-cap005-ai-write-tool-filtering.md. 2026-06-23 RESP-001 Tablet admin editor usability As an editor using a tablet-width viewport, I want the admin editor chrome, account menu, and canvas to remain usable without overlapping or collapsing. At 768x1024, the Site editor loads, toolbar is visible, canvas root is visible, and the account menu trigger remains reachable. Clipped toolbar; canvas root collapsed; account trigger off-screen; horizontal overflow; panel overlap; session state invalidated by earlier publishing specs. Responsive layout is validated through Playwright viewport sizing and visible role/test-id assertions for core editor chrome. tests/e2e/accessibility.e2e.ts; src/admin/layouts; src/admin/pages/site/canvas; src/admin/shared/AccountMenuButton The smoke checks the core editor shell at tablet width, not every panel or workflow; phone-width admin authoring remains future exploratory coverage. Happy: tablet viewport loads Site editor shell. Error: missing toolbar/canvas/account trigger fails. Boundary: 768px viewport. Invalid: unauthenticated access redirects to login outside this row. Permission: owner storage state required. Performance: editor ready within timeout. Mobile: phone-width public page covered by RESP-002. Focused capability/responsive/spotlight Playwright regression passed 2026-06-23; canonical spreadsheet row added after matrix gap discovery 0 None Verification: combined focused Playwright run passed with no failed tests. Initial combined run exposed an E2E isolation defect where RESP-002 publish step-up rotated the shared owner session before command-palette specs; RESP-002 now uses anonymous storage and fresh login, with no product defect recorded. 2026-06-23 SPOT-004 Destructive command two-Enter confirmation As an editor using Command-K, I want destructive commands to require a second confirmation keystroke so I do not delete content accidentally. When a destructive command such as deleting the current page is highlighted, the first Enter switches the row into confirmation state and the second Enter performs the action. Confirmation not shown; action fires on first Enter; double-fire on second Enter; page tree selection stale after delete; keyboard focus leaves palette. Command metadata marks destructive actions; Spotlight state requires an active confirmation before execution and uses keyboard events inside the palette focus trap. tests/e2e/command-palette.e2e.ts; src/admin/spotlight; src/admin/pages/site The regression uses a disposable page context and one destructive command representative of the destructive-command path. Happy: first Enter confirms and second Enter deletes. Error: missing confirm fails. Boundary: two sequential Enter presses. Invalid: non-destructive commands should not require confirm. Permission: command availability follows editor capabilities. Performance: palette responds within timeout. Mobile: keyboard desktop path covered; touch path future. Focused capability/responsive/spotlight Playwright regression passed 2026-06-23; canonical spreadsheet row added after matrix gap discovery 0 None Verification: command-palette.e2e.ts passed inside the combined focused run. SPOT-004 proves the destructive row confirmation copy appears before execution and the command completes only after the second Enter. 2026-06-23 SPOT-005 Destructive command confirmation timeout As an editor using Command-K, I want stale destructive confirmations to expire so an old confirmation state cannot execute later by surprise. After the first Enter starts destructive confirmation, waiting longer than the configured confirmation window clears the confirm prompt and returns the row to normal state. Timer fires too early; timer never fires; row stuck in confirm state; second Enter after timeout still executes; focus leaves palette while waiting. Spotlight confirmation state has a timeout and is cleared before execution can proceed after expiry. tests/e2e/command-palette.e2e.ts; src/admin/spotlight The regression waits beyond the implemented timeout and verifies visible prompt removal; exact millisecond timing is not treated as a product promise. Happy: confirm prompt disappears after timeout. Error: stale confirm remains. Boundary: wait over 5 seconds. Invalid: second Enter after timeout should require confirmation again. Permission: command availability follows editor capabilities. Performance: timer resolves predictably. Mobile: keyboard path only. Focused capability/responsive/spotlight Playwright regression passed 2026-06-23; canonical spreadsheet row added after matrix gap discovery 0 None Verification: command-palette.e2e.ts passed inside the combined focused run. SPOT-005 validates destructive confirmation expiry and guards against stale confirmation execution. 2026-06-23 diff --git a/playwright.config.ts b/playwright.config.ts index cb007150..c63068bb 100644 --- a/playwright.config.ts +++ b/playwright.config.ts @@ -3,6 +3,8 @@ import { OWNER_STATE_FILE } from './tests/e2e/helpers/constants' const ADMIN_BASE_URL = process.env.E2E_ADMIN_BASE_URL ?? 'http://127.0.0.1:5174' process.env.E2E_PUBLIC_BASE_URL ??= 'http://127.0.0.1:3002' +const LOCAL_TRACE = process.env.E2E_TRACE === '1' +const LOCAL_VIDEO = process.env.E2E_VIDEO === '1' export default defineConfig({ testDir: './tests/e2e', @@ -22,21 +24,39 @@ export default defineConfig({ use: { baseURL: ADMIN_BASE_URL, screenshot: 'only-on-failure', - trace: process.env.CI ? 'on-first-retry' : 'retain-on-failure', - video: 'retain-on-failure', + // The full single-worker suite keeps every open SSE request in a trace. + // Recording every local test can therefore consume gigabytes before the + // worker finishes and discards passing artifacts. Keep local capture opt-in; + // CI records only the first retry, where an artifact is actionable. + trace: process.env.CI ? 'on-first-retry' : LOCAL_TRACE ? 'retain-on-failure' : 'off', + video: process.env.CI ? 'on-first-retry' : LOCAL_VIDEO ? 'retain-on-failure' : 'off', }, // The disposable DB is set up once per run, so first-run setup runs in its own - // `setup` project. Every spec depends on it and reuses the owner's auth state; - // specs that need a clean/anonymous session opt out with `test.use({ storageState })`. + // `setup` project. Dashboard preflight then verifies clean-install facts before + // persona/capability specs mutate site-wide users and plugins. `personas` creates + // accounts used by destructive self-management specs; those specs must never + // revoke the shared owner session that ordinary specs reuse. projects: [ { name: 'setup', testMatch: /auth\.setup\.ts$/, }, + { + name: 'dashboard-preflight', + testMatch: /dashboard\.e2e\.ts$/, + dependencies: ['setup'], + use: { storageState: OWNER_STATE_FILE }, + }, + { + name: 'personas', + testMatch: /account-persona\.setup\.ts$/, + dependencies: ['dashboard-preflight'], + }, { name: 'e2e', testMatch: '**/*.e2e.ts', - dependencies: ['setup'], + testIgnore: /dashboard\.e2e\.ts$/, + dependencies: ['setup', 'personas'], use: { storageState: OWNER_STATE_FILE }, }, ], diff --git a/tests/e2e/account-persona.setup.ts b/tests/e2e/account-persona.setup.ts new file mode 100644 index 00000000..eaef7609 --- /dev/null +++ b/tests/e2e/account-persona.setup.ts @@ -0,0 +1,36 @@ +import { expect, test as setup } from '@playwright/test' +import { ACCOUNT_PERSONA } from './helpers/constants' +import { completeStepUp, expectLoggedIn, login, loginAs, logout } from './helpers' + +/** + * Create the identity used by destructive account self-management tests. + * + * Those tests rotate credentials, toggle MFA, and revoke other sessions. Keeping + * them on a separate Admin prevents them from invalidating the owner session + * serialized by `auth.setup.ts` and consumed by the rest of the suite. + */ +setup('create the account-management persona', async ({ page }) => { + await login(page) + await page.goto('/admin/users') + await expect(page.getByRole('table', { name: 'Users' })).toBeVisible() + + const alreadyExists = await page.getByText(ACCOUNT_PERSONA.email).isVisible() + + if (!alreadyExists) { + await page.getByRole('button', { name: 'Create User', exact: true }).click() + await page.locator('input[name="new-user-email-address"]').fill(ACCOUNT_PERSONA.email) + await page.locator('input[name="new-user-display-name"]').fill(ACCOUNT_PERSONA.displayName) + await page.locator('input[name="new-user-initial-password"]').fill(ACCOUNT_PERSONA.password) + await page.locator('select[name="new-user-role"]').selectOption({ label: ACCOUNT_PERSONA.role }) + await page.locator('button[form="users-page-user-form"]').click() + await completeStepUp(page) + await expect(page.getByText(ACCOUNT_PERSONA.email)).toBeVisible() + } + + // Fresh and reused runs both prove the declared baseline credentials work. + // Reuse is safe only when a prior run also restored the persona's MFA state; + // otherwise this login stops at the MFA challenge and fails setup explicitly. + await logout(page) + await loginAs(page, ACCOUNT_PERSONA.email, ACCOUNT_PERSONA.password) + await expectLoggedIn(page) +}) diff --git a/tests/e2e/account.e2e.ts b/tests/e2e/account.e2e.ts index dd43d7ba..f8f216c1 100644 --- a/tests/e2e/account.e2e.ts +++ b/tests/e2e/account.e2e.ts @@ -1,15 +1,32 @@ import { createHmac } from 'node:crypto' import { expect, test, type Locator, type Page } from '@playwright/test' import { + ACCOUNT_PERSONA, ANONYMOUS_STATE, OWNER, - completeStepUp, + completeStepUp as runStepUp, expectLoggedIn, - login, + login as loginOwner, loginAs, logout, } from './helpers' +/** + * Account self-management is deliberately exercised as the dedicated persona. + * Security actions in this file revoke account-global sessions, so using the + * owner would invalidate `OWNER_STATE_FILE` and log later specs out. + */ +async function login(page: Page): Promise { + await loginAs(page, ACCOUNT_PERSONA.email, ACCOUNT_PERSONA.password) +} + +async function completeStepUp( + page: Page, + password: string = ACCOUNT_PERSONA.password, +): Promise { + await runStepUp(page, password) +} + /** A minimal but valid 1×1 PNG for the avatar upload. */ const PNG_1X1 = Buffer.from( 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNkYPhfDwAChwGA60e6kgAAAABJRU5ErkJggg==', @@ -53,7 +70,7 @@ function totpCode(secret: string, now = Date.now()): string { async function completeStepUpWithMfa(page: Page, secret: string): Promise { const dialog = page.getByTestId('step-up-dialog') await expect(dialog).toBeVisible({ timeout: 20_000 }) - await page.getByTestId('step-up-password').fill(OWNER.password) + await page.getByTestId('step-up-password').fill(ACCOUNT_PERSONA.password) await page.getByTestId('step-up-mfa-code').fill(totpCode(secret)) await page.getByTestId('step-up-confirm').click() await expect(dialog).toBeHidden({ timeout: 20_000 }) @@ -65,7 +82,7 @@ async function completeStepUpWithMfaIfOpened(page: Page, secret: string): Promis .waitFor({ state: 'visible', timeout: 1_000 }) .then(() => true, () => false) if (!opened) return - await page.getByTestId('step-up-password').fill(OWNER.password) + await page.getByTestId('step-up-password').fill(ACCOUNT_PERSONA.password) await page.getByTestId('step-up-mfa-code').fill(totpCode(secret)) await page.getByTestId('step-up-confirm').click() await expect(dialog).toBeHidden({ timeout: 20_000 }) @@ -127,7 +144,7 @@ async function createAccountActivityUser( await page.locator('input[name="new-user-initial-password"]').fill(user.password) await page.locator('select[name="new-user-role"]').selectOption({ label: user.role }) await page.locator('button[form="users-page-user-form"]').click() - await completeStepUp(page) + await completeStepUp(page, OWNER.password) await expect(page.getByText(user.email)).toBeVisible() } @@ -155,11 +172,11 @@ async function clearUploadedAvatarIfPresent(page: Page): Promise { * without enabling MFA. */ test.describe('account', () => { - test.describe('profile basics', () => { - test.use({ storageState: ANONYMOUS_STATE }) + test.use({ storageState: ANONYMOUS_STATE }) + test.describe('profile basics', () => { test('updates a display name that persists (ADMIN-002 / ACCOUNT-001)', async ({ page }) => { - const displayName = `Owner ${Date.now().toString(36)}` + const displayName = `Account Persona ${Date.now().toString(36)}` await login(page) await page.goto('/admin/account') @@ -187,7 +204,7 @@ test.describe('account', () => { const originalDisplayName = await page.getByTestId('profile-display-name').inputValue() const originalEmail = await page.getByTestId('profile-email').inputValue() - const draftDisplayName = `Owner mobile ${Date.now().toString(36)}` + const draftDisplayName = `Account Persona mobile ${Date.now().toString(36)}` await page.getByTestId('profile-display-name').fill(draftDisplayName) await expect(page.getByTestId('profile-save')).toBeEnabled() @@ -212,6 +229,7 @@ test.describe('account', () => { }) test('uploads a profile picture that persists (ADMIN-002 / ACCOUNT-002)', async ({ page }) => { + await login(page) await page.goto('/admin/account') await page.getByTestId('account-tab-profile').click() @@ -232,6 +250,7 @@ test.describe('account', () => { test('removes a profile picture that stays removed after reload (ACCOUNT-002)', async ({ page, }) => { + await login(page) await page.goto('/admin/account') await page.getByTestId('account-tab-profile').click() @@ -259,6 +278,7 @@ test.describe('account', () => { test('rejects an unsupported profile picture with inline feedback (ACCOUNT-002)', async ({ page, }) => { + await login(page) await page.goto('/admin/account') await page.getByTestId('account-tab-profile').click() @@ -278,6 +298,7 @@ test.describe('account', () => { test('rejects an oversized profile picture with inline feedback (ACCOUNT-002)', async ({ page, }) => { + await login(page) await page.goto('/admin/account') await page.getByTestId('account-tab-profile').click() @@ -297,6 +318,7 @@ test.describe('account', () => { page, }) => { await page.setViewportSize({ width: 390, height: 844 }) + await login(page) await page.goto('/admin/account') await page.getByTestId('account-tab-profile').click() @@ -325,8 +347,6 @@ test.describe('account', () => { }) test.describe('security', () => { - test.use({ storageState: ANONYMOUS_STATE }) - test('starts and cancels MFA setup without enabling MFA (ADMIN-003)', async ({ page, }) => { @@ -423,20 +443,18 @@ test.describe('account', () => { }) test.describe('activity', () => { - test.use({ storageState: ANONYMOUS_STATE }) - test('shows failed and successful login attempts in sign-in history (AUTH-006)', async ({ page, }) => { await page.goto('/admin') await expect(page.getByRole('heading', { name: 'Admin Login' })).toBeVisible() - await page.getByLabel('Email').fill(OWNER.email) - await page.getByLabel('Password').fill(`${OWNER.password}-wrong`) + await page.getByLabel('Email').fill(ACCOUNT_PERSONA.email) + await page.getByLabel('Password').fill(`${ACCOUNT_PERSONA.password}-wrong`) await page.getByRole('button', { name: 'Sign In' }).click() await expect(page.getByRole('alert')).toHaveText(/invalid email or password/i) - await page.getByLabel('Password').fill(OWNER.password) + await page.getByLabel('Password').fill(ACCOUNT_PERSONA.password) await page.getByRole('button', { name: 'Sign In' }).click() await expectLoggedIn(page) @@ -463,7 +481,7 @@ test.describe('account', () => { page, browser, }) => { - await login(page) + await loginOwner(page) const suffix = Date.now().toString(36) const accountUser = { email: `activity-lockout-${suffix}@example.com`, @@ -529,8 +547,6 @@ test.describe('account', () => { }) test.describe('sessions', () => { - test.use({ storageState: ANONYMOUS_STATE }) - test('lists active devices and signs out everywhere else (AUTH-004)', async ({ page, browser, @@ -598,8 +614,6 @@ test.describe('account', () => { }) test.describe('mfa lifecycle', () => { - test.use({ storageState: ANONYMOUS_STATE }) - test('keeps MFA setup dialog usable at mobile width (ACCOUNT-004)', async ({ page }) => { await page.setViewportSize({ width: 390, height: 844 }) await login(page) @@ -695,8 +709,8 @@ test.describe('account', () => { await expect(page.getByTestId('security-recovery-regenerate')).toBeEnabled() await logout(page) - await page.getByLabel('Email').fill(OWNER.email) - await page.getByLabel('Password').fill(OWNER.password) + await page.getByLabel('Email').fill(ACCOUNT_PERSONA.email) + await page.getByLabel('Password').fill(ACCOUNT_PERSONA.password) await page.getByRole('button', { name: 'Sign In' }).click() await expect( page.getByRole('heading', { name: 'Two-Factor Authentication' }), @@ -757,8 +771,8 @@ test.describe('account', () => { await expect(page.getByTestId('security-mfa-card')).toContainText('On') await logout(page) - await page.getByLabel('Email').fill(OWNER.email) - await page.getByLabel('Password').fill(OWNER.password) + await page.getByLabel('Email').fill(ACCOUNT_PERSONA.email) + await page.getByLabel('Password').fill(ACCOUNT_PERSONA.password) await page.getByRole('button', { name: 'Sign In' }).click() await expect( page.getByRole('heading', { name: 'Two-Factor Authentication' }), @@ -783,7 +797,7 @@ test.describe('account', () => { await expect(stepUpDialog.getByTestId('step-up-confirm')).toBeDisabled() const wrongStepUpCode = totpCode(secret) === '000000' ? '000001' : '000000' - await page.getByTestId('step-up-password').fill(OWNER.password) + await page.getByTestId('step-up-password').fill(ACCOUNT_PERSONA.password) await page.getByTestId('step-up-mfa-code').fill(wrongStepUpCode) await page.getByTestId('step-up-confirm').click() await expect(stepUpDialog.getByRole('alert')).toHaveText('Invalid authentication code') @@ -813,8 +827,8 @@ test.describe('account', () => { await logout(page) await page.setViewportSize({ width: 390, height: 844 }) - await page.getByLabel('Email').fill(OWNER.email) - await page.getByLabel('Password').fill(OWNER.password) + await page.getByLabel('Email').fill(ACCOUNT_PERSONA.email) + await page.getByLabel('Password').fill(ACCOUNT_PERSONA.password) await page.getByRole('button', { name: 'Sign In' }).click() await expect( @@ -854,8 +868,8 @@ test.describe('account', () => { if (!recoveryCode) throw new Error('MFA setup did not render a recovery code') await logout(page) - await page.getByLabel('Email').fill(OWNER.email) - await page.getByLabel('Password').fill(OWNER.password) + await page.getByLabel('Email').fill(ACCOUNT_PERSONA.email) + await page.getByLabel('Password').fill(ACCOUNT_PERSONA.password) await page.getByRole('button', { name: 'Sign In' }).click() await expect( page.getByRole('heading', { name: 'Two-Factor Authentication' }), @@ -872,8 +886,8 @@ test.describe('account', () => { ) await logout(page) - await page.getByLabel('Email').fill(OWNER.email) - await page.getByLabel('Password').fill(OWNER.password) + await page.getByLabel('Email').fill(ACCOUNT_PERSONA.email) + await page.getByLabel('Password').fill(ACCOUNT_PERSONA.password) await page.getByRole('button', { name: 'Sign In' }).click() await expect( page.getByRole('heading', { name: 'Two-Factor Authentication' }), @@ -896,8 +910,6 @@ test.describe('account', () => { }) test.describe('password', () => { - test.use({ storageState: ANONYMOUS_STATE }) - test('keeps password change usable at mobile width (ACCOUNT-003)', async ({ page }) => { await page.setViewportSize({ width: 390, height: 844 }) await login(page) @@ -931,7 +943,7 @@ test.describe('account', () => { await expect(dialog).toBeHidden() }) - test('changes password, rejects invalid form input, and restores the shared credential (ACCOUNT-003)', async ({ + test('changes password, rejects invalid form input, and restores the persona credential (ACCOUNT-003)', async ({ page, }) => { const temporaryPassword = `changed-pass-${Date.now().toString(36)}` @@ -961,8 +973,8 @@ test.describe('account', () => { await expect(page.getByText('Password updated. Other devices were signed out.')).toBeVisible() await logout(page) - await page.getByLabel('Email').fill(OWNER.email) - await page.getByLabel('Password').fill(OWNER.password) + await page.getByLabel('Email').fill(ACCOUNT_PERSONA.email) + await page.getByLabel('Password').fill(ACCOUNT_PERSONA.password) await page.getByRole('button', { name: 'Sign In' }).click() await expect(page.getByRole('alert')).toHaveText(/invalid email or password/i) @@ -974,8 +986,8 @@ test.describe('account', () => { await page.getByTestId('account-tab-security').click() await page.getByTestId('security-change-password').click() await expect(dialog).toBeVisible() - await page.getByTestId('security-password-new').fill(OWNER.password) - await page.getByTestId('security-password-confirm').fill(OWNER.password) + await page.getByTestId('security-password-new').fill(ACCOUNT_PERSONA.password) + await page.getByTestId('security-password-confirm').fill(ACCOUNT_PERSONA.password) await page.getByTestId('security-password-submit').click() await completeStepUp(page, temporaryPassword) await expect(dialog).toBeHidden({ timeout: 20_000 }) diff --git a/tests/e2e/ai.e2e.ts b/tests/e2e/ai.e2e.ts index 74177afa..a62f17a5 100644 --- a/tests/e2e/ai.e2e.ts +++ b/tests/e2e/ai.e2e.ts @@ -425,8 +425,8 @@ test.describe('AI settings', () => { page, }) => { const fakeOllama = await startFakeOllamaServer('E2E bridge reply.', { - id: 'call_read_document', - name: 'read_document', + id: 'call_site_read_document', + name: 'site_read_document', input: {}, }) const suffix = Date.now().toString(36) @@ -453,7 +453,7 @@ test.describe('AI settings', () => { await assistantPanel.getByRole('button', { name: 'Send' }).click() await expect( - assistantPanel.getByRole('status', { name: 'Completed read_document' }), + assistantPanel.getByRole('status', { name: 'Completed Reading document' }), ).toBeVisible({ timeout: 20_000 }) await expect(assistantPanel.getByText('E2E bridge reply.')).toBeVisible({ timeout: 20_000, @@ -465,9 +465,9 @@ test.describe('AI settings', () => { const firstBody = fakeOllama.requests.chatBodies[0] ?? '' const secondBody = fakeOllama.requests.chatBodies[1] ?? '' expect(firstBody).toContain('"tools"') - expect(firstBody).toContain('"read_document"') + expect(firstBody).toContain('"site_read_document"') expect(secondBody).toContain('"role":"tool"') - expect(secondBody).toContain('"tool_call_id":"call_read_document"') + expect(secondBody).toContain('"tool_call_id":"call_site_read_document"') }) await test.step('clear seeded conversations/defaults and delete the credential', async () => { @@ -549,7 +549,7 @@ test.describe('AI settings', () => { const menu = page.getByRole('menu', { name: 'Conversation history' }) const savedChat = menu .getByRole('menuitemradio') - .filter({ hasText: 'New conversation' }) + .filter({ hasText: prompt }) .first() await expect(savedChat).toBeVisible() await savedChat.click() @@ -562,7 +562,7 @@ test.describe('AI settings', () => { const assistantPanel = page.getByRole('complementary', { name: 'AI Assistant' }) await assistantPanel.getByRole('button', { name: 'Conversation history' }).click() const menu = page.getByRole('menu', { name: 'Conversation history' }) - await menu.getByRole('button', { name: 'Delete chat "New conversation"' }).click() + await menu.getByRole('button', { name: `Delete chat "${prompt}"` }).click() await expect(menu.getByText('No chats yet.')).toBeVisible() await expect(assistantPanel.getByText('E2E conversation reply.')).toHaveCount(0) }) @@ -604,6 +604,7 @@ test.describe.serial('AI write-tool capability filtering', () => { const email = `cap-ai-chat-only-${suffix}@example.com` const password = 'cap-ai-chat-only-pass-12345' const label = `CAP Chat Tools Ollama ${suffix}` + let credentialCreated = false try { await test.step('owner creates a temporary provider-setup chat persona', async () => { @@ -629,6 +630,7 @@ test.describe.serial('AI write-tool capability filtering', () => { await personaPage.goto('/admin/ai') await expect(personaPage.getByRole('heading', { name: 'AI' })).toBeVisible() await addOllamaCredential(personaPage, label, fakeOllama.baseUrl) + credentialCreated = true await expect(personaPage.getByText(label)).toBeVisible({ timeout: 20_000 }) await expect.poll(() => fakeOllama.requests.tags).toBeGreaterThan(0) }) @@ -659,17 +661,42 @@ test.describe.serial('AI write-tool capability filtering', () => { await test.step('provider request contains read tools but no mutating tools', async () => { await expect.poll(() => fakeOllama.requests.chats).toBe(1) const toolNames = toolNamesFromChatBody(fakeOllama.requests.chatBodies[0] ?? '') - expect(toolNames).toContain('read_document') - expect(toolNames).toContain('render_snapshot') - expect(toolNames).not.toContain('insertHtml') - expect(toolNames).not.toContain('replaceNodeHtml') - expect(toolNames).not.toContain('updateNodeProps') - expect(toolNames).not.toContain('applyCss') - expect(toolNames).not.toContain('write_code_asset') - expect(toolNames).not.toContain('addPage') - expect(toolNames).not.toContain('deletePage') + expect(toolNames).toContain('site_read_document') + expect(toolNames).toContain('site_render_snapshot') + expect(toolNames).not.toContain('site_insert_html') + expect(toolNames).not.toContain('site_replace_node_html') + expect(toolNames).not.toContain('site_update_node_props') + expect(toolNames).not.toContain('site_apply_css') + expect(toolNames).not.toContain('site_write_code_asset') + expect(toolNames).not.toContain('site_add_page') + expect(toolNames).not.toContain('site_delete_page') }) } finally { + if (credentialCreated) { + // Credential creation auto-seeds the site-wide defaults. Restore the + // temporary capability so the persona can remove the fixture it owns, + // then clear those defaults before deletion (the FK is restrictive). + await setRoleCapabilities(page, roleName, [ + 'View site', + 'Use AI chat', + 'Manage AI providers', + ]) + await personaPage.evaluate(async () => { + for (const scope of ['site', 'content', 'data', 'plugin']) { + const response = await fetch(`/admin/api/ai/defaults/${scope}`, { + method: 'DELETE', + }) + if (!response.ok) { + throw new Error(`Failed to clear ${scope} default: ${response.status}`) + } + } + }) + await personaPage.goto('/admin/ai') + const credentialCard = personaPage.locator('div').filter({ hasText: label }).first() + await expect(credentialCard).toBeVisible() + await credentialCard.getByRole('button', { name: 'Delete' }).click() + await expect(personaPage.getByText(label)).toHaveCount(0) + } await personaContext.close() } } finally { diff --git a/tests/e2e/capabilities.e2e.ts b/tests/e2e/capabilities.e2e.ts index a4e1cecb..cb3248d5 100644 --- a/tests/e2e/capabilities.e2e.ts +++ b/tests/e2e/capabilities.e2e.ts @@ -8,6 +8,7 @@ import { login, loginAs, openLayersPanel, + openSitePanel, openSiteEditor, saveDraft, setPropValue, @@ -592,7 +593,7 @@ test.describe.serial('site import step-up boundaries', () => { const suffix = Date.now().toString(36) const bundledTableName = `CAP Imported Bundle ${suffix}` const localTableName = `CAP Local Table ${suffix}` - let bundle: unknown + let bundle: Buffer const setupContext = await browser.newContext() const setupPage = await setupContext.newPage() @@ -613,16 +614,14 @@ test.describe.serial('site import step-up boundaries', () => { await expectDataTableVisible(page, bundledTableName) await expectDataTableVisible(page, localTableName) - await openReplaceImportReview(page, bundle, `cap-replace-${suffix}.json`) + await openReplaceImportReview(page, bundle, `cap-replace-${suffix}.zip`) await page.getByRole('button', { name: 'Replace site' }).click() const stepUpDialog = page.getByTestId('step-up-dialog') await expect(stepUpDialog).toBeVisible() await stepUpDialog.getByRole('button', { name: 'Cancel' }).click() await expect(stepUpDialog).toBeHidden() - await closeImportReview(page) await expectDataTablePresence(page, localTableName, true) - await openReplaceImportReview(page, bundle, `cap-replace-retry-${suffix}.json`) await page.getByRole('button', { name: 'Replace site' }).click() await expect(stepUpDialog).toBeVisible() await page.getByTestId('step-up-password').fill('wrong-password-12345') @@ -633,7 +632,7 @@ test.describe.serial('site import step-up boundaries', () => { await page.getByTestId('step-up-password').fill(OWNER.password) await page.getByTestId('step-up-confirm').click() await expect(stepUpDialog).toBeHidden({ timeout: 20_000 }) - await expect(siteImportDialog(page, 'Review bundle')).toHaveCount(0) + await expect(siteImportDialog(page, 'Review import')).toBeHidden() await expectDataTablePresence(page, bundledTableName, true) await expectDataTablePresence(page, localTableName, false) } finally { @@ -1120,10 +1119,8 @@ async function withPersona( async function openNamedPage(page: Page, name: string): Promise { await openReadableSiteEditor(page) + await openSitePanel(page) const item = page.getByRole('treeitem', { name: `Open page ${name}` }) - if (!(await item.isVisible().catch(() => false))) { - await page.getByRole('button', { name: 'Open Site panel' }).click() - } await expect(item).toBeVisible() await item.click() await expect(item).toHaveAttribute('aria-selected', 'true') @@ -1238,8 +1235,8 @@ async function createCustomDataTable(page: Page, tableName: string): Promise { - return page.evaluate(async () => { +async function exportCmsBundle(page: Page): Promise { + const bytes = await page.evaluate(async () => { const response = await fetch('/admin/api/cms/export', { method: 'POST', credentials: 'include', @@ -1254,13 +1251,14 @@ async function exportCmsBundle(page: Page): Promise { if (!response.ok) { throw new Error(`Export failed with HTTP ${response.status}: ${await response.text()}`) } - return response.json() + return Array.from(new Uint8Array(await response.arrayBuffer())) }) + return Buffer.from(bytes) } async function openReplaceImportReview( page: Page, - bundle: unknown, + bundle: Buffer, filename: string, ): Promise { await openDataWorkspace(page) @@ -1268,23 +1266,18 @@ async function openReplaceImportReview( await expect(siteImportDialog(page, 'Import site')).toBeVisible() await page.locator('input[type="file"]').first().setInputFiles({ name: filename, - mimeType: 'application/json', - buffer: Buffer.from(JSON.stringify(bundle)), + mimeType: 'application/zip', + buffer: bundle, }) - const review = siteImportDialog(page, 'Review bundle') + const review = siteImportDialog(page, 'Review import') await expect(review).toBeVisible({ timeout: 20_000 }) - await expect(review.getByText(filename, { exact: true })).toBeVisible() - await review.getByText('Replace everything', { exact: true }).click() + await expect(review).toContainText(filename) + await review.getByRole('radio', { name: 'Replace everything' }).click() await expect(page.getByRole('button', { name: 'Replace site' })).toBeEnabled() } -async function closeImportReview(page: Page): Promise { - await page.keyboard.press('Escape') - await expect(siteImportDialog(page, 'Review bundle')).toHaveCount(0) -} - -function siteImportDialog(page: Page, name: 'Import site' | 'Review bundle') { +function siteImportDialog(page: Page, name: 'Import site' | 'Review import') { return page .getByRole('dialog', { name }) .or(page.getByRole('alertdialog', { name })) diff --git a/tests/e2e/content.e2e.ts b/tests/e2e/content.e2e.ts index 8fde3181..b837dd43 100644 --- a/tests/e2e/content.e2e.ts +++ b/tests/e2e/content.e2e.ts @@ -4,6 +4,7 @@ import { OWNER, canvasFrame, completeStepUp, + insertModuleViaPicker, insertNotchModule, login, openSiteEditor, @@ -42,9 +43,7 @@ test.describe('content', () => { await page.goto('/admin/content') await test.step('create a draft and open the rich body editor', async () => { - const newPost = page.getByRole('button', { name: 'New post', exact: true }) - await expect(newPost).toBeEnabled() - await newPost.click() + await startNewPost(page) await page.getByRole('textbox', { name: 'Title', exact: true }).fill(title) await expect(page.getByTestId('content-body-editor')).toBeVisible() }) @@ -68,9 +67,7 @@ test.describe('content', () => { }) await test.step('save and reload the rich body content', async () => { - await page.getByRole('button', { name: 'More publishing actions' }).click() - await page.getByTestId('toolbar-content-save-draft-action').click() - await expect(entryRow(page, title)).toBeVisible({ timeout: 20_000 }) + await saveSelectedDraft(page, title) await page.reload() await expect(entryRow(page, title)).toBeVisible() @@ -112,13 +109,12 @@ test.describe('content', () => { page, }) => { await login(page) + const suffix = Date.now().toString(36) - await test.step('publish the site snapshot live preview renders through', async () => { - await openSiteEditor(page) - await publishDraft(page) + await test.step('author and publish the Posts template live preview renders through', async () => { + await createPublishedPostsTemplate(page, suffix, 10) }) - const suffix = Date.now().toString(36) const savedTitle = `E2E Live Preview ${suffix}` const draftTitle = `${savedTitle} Draft` const savedBody = 'Saved body before the live preview draft edit.' @@ -216,12 +212,12 @@ test.describe('content', () => { browser, }) => { await login(page) + const suffix = Date.now().toString(36) - await test.step('publish the site snapshot that provides the entry template', async () => { - await ensureSiteSnapshotPublished(page) + await test.step('author and publish the Posts template for the public entry route', async () => { + await createPublishedPostsTemplate(page, suffix, 20) }) - const suffix = Date.now().toString(36) const title = `E2E Formatted ${suffix}` const slug = `e2e-formatted-${suffix}` const boldText = `bold ${suffix}` @@ -230,9 +226,7 @@ test.describe('content', () => { await test.step('create formatted body content and save it', async () => { await page.goto('/admin/content') - const newPost = page.getByRole('button', { name: 'New post', exact: true }) - await expect(newPost).toBeEnabled() - await newPost.click() + await startNewPost(page) await page.getByRole('textbox', { name: 'Title', exact: true }).fill(title) await page.getByRole('textbox', { name: 'Slug' }).fill(slug) @@ -455,9 +449,7 @@ async function createPostDraft( await test.step('create a new post', async () => { // The posts collection is selected by default; the New action enables once // it has loaded. `exact` avoids matching the canvas "New Post" CTA. - const newPost = page.getByRole('button', { name: 'New post', exact: true }) - await expect(newPost).toBeEnabled() - await newPost.click() + await startNewPost(page) await page.getByRole('textbox', { name: 'Title', exact: true }).fill(title) await page.getByTestId('content-body-editor').click() @@ -478,9 +470,7 @@ async function createPostDraftWithSlug( await page.goto('/admin/content') await test.step('create a new post with a stable public slug', async () => { - const newPost = page.getByRole('button', { name: 'New post', exact: true }) - await expect(newPost).toBeEnabled() - await newPost.click() + await startNewPost(page) await page.getByRole('textbox', { name: 'Title', exact: true }).fill(title) await page.getByRole('textbox', { name: 'Slug' }).fill(slug) @@ -536,7 +526,7 @@ async function createPostsTemplate( await dialog.getByLabel('Applies to').click() await page.getByRole('option', { name: 'Post types' }).click() await dialog.getByLabel('Posts').setChecked(true) - await dialog.getByLabel('Priority').fill('260') + await dialog.getByLabel('Priority').fill('150') await dialog.getByRole('button', { name: 'Save' }).click() await expect(dialog).toBeHidden() @@ -545,22 +535,56 @@ async function createPostsTemplate( async function saveSelectedDraft(page: Page, title: string): Promise { await page.getByRole('button', { name: 'More publishing actions' }).click() + const saveResponse = page.waitForResponse((response) => + /\/admin\/api\/cms\/data\/rows\/[^/]+$/.test(new URL(response.url()).pathname) && + response.request().method() === 'PATCH', + ) await page.getByTestId('toolbar-content-save-draft-action').click() + expect((await saveResponse).ok()).toBe(true) // The new title replaces the "Untitled draft" placeholder once saved. await expect(entryRow(page, title)).toBeVisible({ timeout: 20_000 }) } -async function ensureSiteSnapshotPublished(page: Page): Promise { +async function startNewPost(page: Page): Promise { + const previousRow = new URL(page.url()).searchParams.get('row') + const newPost = page.getByRole('button', { name: 'New post', exact: true }) + await expect(newPost).toBeEnabled() + await newPost.click() + await page.waitForURL((url) => { + const nextRow = url.searchParams.get('row') + return nextRow !== null && nextRow !== previousRow + }) + await expect(page.getByRole('textbox', { name: 'Title', exact: true })).toHaveValue('') +} + +async function createPublishedPostsTemplate( + page: Page, + suffix: string, + priority: number, +): Promise { await openSiteEditor(page) - const publishButton = page.getByTestId('toolbar-publish-btn') - await expect(publishButton).toBeVisible() - if (await publishButton.isEnabled()) { - await publishDraft(page) - return - } - await expect( - page.getByRole('button', { name: 'Published', exact: true }), - ).toBeDisabled() + await openSitePanel(page) + await page.getByRole('button', { name: 'New template', exact: true }).click() + + const dialog = page.getByRole('dialog', { name: 'Template settings' }) + await expect(dialog).toBeVisible() + await dialog.getByLabel('Name').fill(`E2E Posts Template ${suffix}`) + await dialog.getByLabel('Slug').fill(`e2e-posts-template-${suffix}`) + await dialog.getByLabel('Applies to').click() + await page.getByRole('option', { name: 'Post types' }).click() + await dialog.getByLabel('Posts').setChecked(true) + await dialog.getByLabel('Priority').fill(String(priority)) + await dialog.getByRole('button', { name: 'Save' }).click() + await expect(dialog).toBeHidden() + + await insertNotchModule(page, 'text') + await setPropValue(page, 'text', '{currentEntry.title}') + await page.locator('#ctrl-tag').click() + await page.getByRole('option', { name: 'Heading 1', exact: true }).click() + await expect(page.locator('#ctrl-tag')).toHaveValue('Heading 1') + await insertModuleViaPicker(page, 'base.outlet') + await saveDraft(page) + await publishDraft(page) } /** The entry's row button in the content explorer list. */ diff --git a/tests/e2e/dashboard.e2e.ts b/tests/e2e/dashboard.e2e.ts index f217788b..1f98bfae 100644 --- a/tests/e2e/dashboard.e2e.ts +++ b/tests/e2e/dashboard.e2e.ts @@ -208,11 +208,11 @@ test.describe('dashboard', () => { const panel = onboardingPanel(page) await expect(panel).toBeVisible({ timeout: 20_000 }) - await expect(panel).toContainText('2 of 5 steps complete.') + await expect(panel).toContainText('1 of 5 steps complete.') await expectOnboardingStep(panel, 'Set site identity', 'Completed', 'Open settings') - await expectOnboardingStep(panel, 'Choose Core Framework import', 'In progress', 'Configure') - await expectOnboardingStep(panel, 'Create your first page', 'Completed', 'New page') + await expectOnboardingStep(panel, 'Choose Core Framework import', 'In progress', 'Import') + await expectOnboardingStep(panel, 'Create your first page', 'Not started', 'New page') await expectOnboardingStep(panel, 'Install a plugin', 'Not started', 'Browse plugins') await expectOnboardingStep(panel, 'Invite your team', 'Not started', 'Add members') diff --git a/tests/e2e/forms.e2e.ts b/tests/e2e/forms.e2e.ts index 7514c055..02ebc7bd 100644 --- a/tests/e2e/forms.e2e.ts +++ b/tests/e2e/forms.e2e.ts @@ -264,7 +264,9 @@ async function expectPublishedFormAssets( page: Page, responses: PublicAssetResponse[], ): Promise { - await expect(page.locator('link[rel="stylesheet"][href*="/_instatic/css/"]')).toHaveCount(1) + await expect( + page.locator('link[rel="stylesheet"][href*="/_instatic/css/reset-"]'), + ).toHaveCount(1) await expect(page.locator('script[src*="/_instatic/module-js/base.form.js"]')).toHaveCount(1) await expectPublicAssetResponse(responses, /^\/_instatic\/css\/.+\.css$/, 'text/css') await expectPublicAssetResponse( diff --git a/tests/e2e/helpers/auth.ts b/tests/e2e/helpers/auth.ts index 23259569..15df1e05 100644 --- a/tests/e2e/helpers/auth.ts +++ b/tests/e2e/helpers/auth.ts @@ -60,7 +60,7 @@ export async function completeStepUp( ): Promise { const dialog = page.getByTestId('step-up-dialog') const opened = await dialog - .waitFor({ state: 'visible', timeout: 10_000 }) + .waitFor({ state: 'visible', timeout: 2_000 }) .then(() => true, () => false) if (!opened) return await page.getByTestId('step-up-password').fill(password) diff --git a/tests/e2e/helpers/constants.ts b/tests/e2e/helpers/constants.ts index 7d0dcc97..acafa923 100644 --- a/tests/e2e/helpers/constants.ts +++ b/tests/e2e/helpers/constants.ts @@ -5,10 +5,10 @@ * `.tmp/e2e-*` database once per run and then serves a single shared stack: * one admin origin, one public origin, one SQLite database. Every spec runs * serially against that shared state (`workers: 1`), so these constants are the - * single source of truth for the owner account and origins. + * single source of truth for the suite identities and origins. */ -/** First-run owner created by the `setup` project. Reused by every spec. */ +/** First-run owner created by the `setup` project and reused by ordinary specs. */ export const OWNER = { email: 'owner.e2e@example.com', password: 'qwerty123456', @@ -33,3 +33,16 @@ export const OWNER_STATE_FILE = '.tmp/e2e-owner-state.json' * owner state. */ export const ANONYMOUS_STATE = { cookies: [], origins: [] } + +/** + * Dedicated identity for account-global security flows. Password changes, MFA + * changes, and "sign out everywhere else" revoke other sessions for the target + * account, so `account.e2e.ts` uses this Admin instead of invalidating the owner + * session saved in `OWNER_STATE_FILE` for the rest of the suite. + */ +export const ACCOUNT_PERSONA = { + email: 'account.persona.e2e@example.com', + password: 'account-persona-pass-12345', + displayName: 'Account Persona', + role: 'Admin', +} as const diff --git a/tests/e2e/helpers/editor.ts b/tests/e2e/helpers/editor.ts index 544a280c..c39548e2 100644 --- a/tests/e2e/helpers/editor.ts +++ b/tests/e2e/helpers/editor.ts @@ -90,6 +90,30 @@ export async function openLayersPanel(page: Page): Promise { await expect(tree).toBeVisible() } +type ExplorerTab = 'Site' | 'Code' | 'Media' + +/** Open one of the consolidated Explorer tabs and wait for it to become active. */ +export async function openExplorerTab(page: Page, tab: ExplorerTab): Promise { + const explorer = page.getByRole('complementary', { name: 'Explorer' }) + if (!(await explorer.isVisible().catch(() => false))) { + await page.getByRole('button', { name: 'Open Explorer panel' }).click() + } + + const tabButton = explorer.getByRole('button', { name: tab, exact: true }) + if ((await tabButton.getAttribute('aria-pressed')) !== 'true') { + await tabButton.click() + } + await expect(tabButton).toHaveAttribute('aria-pressed', 'true') +} + +/** Open the Code Explorer tab (stylesheets + scripts). */ +export async function openCodePanel(page: Page): Promise { + await openExplorerTab(page, 'Code') + await expect( + page.getByRole('button', { name: 'New stylesheet', exact: true }), + ).toBeVisible() +} + /** Select a layer in the DOM/layers tree by its display name. */ export async function selectTreeLayer(page: Page, name: string): Promise { await page.getByRole('treeitem', { name }).first().click() @@ -116,13 +140,7 @@ export async function setPropValue( */ export async function openSitePanel(page: Page): Promise { const newPageButton = page.getByRole('button', { name: 'New page', exact: true }) - if (!(await newPageButton.isVisible().catch(() => false))) { - const explorer = page.getByRole('complementary', { name: 'Explorer' }) - if (!(await explorer.isVisible().catch(() => false))) { - await page.getByRole('button', { name: 'Open Explorer panel' }).click() - } - await explorer.getByRole('button', { name: 'Site', exact: true }).click() - } + await openExplorerTab(page, 'Site') await expect(newPageButton).toBeVisible() } @@ -159,7 +177,12 @@ export async function saveDraft(page: Page): Promise { await page.keyboard.press('Escape') } await expect(saveAction).toBeVisible() + const saveResponse = page.waitForResponse((response) => + new URL(response.url()).pathname === '/admin/api/cms/site-document' && + response.request().method() === 'PUT', + ) await saveAction.click() + expect((await saveResponse).ok()).toBe(true) await expect(page.getByRole('status', { name: 'Draft saved' })).toBeVisible({ timeout: 20_000, }) diff --git a/tests/e2e/media.e2e.ts b/tests/e2e/media.e2e.ts index a49ad1dc..9813abb8 100644 --- a/tests/e2e/media.e2e.ts +++ b/tests/e2e/media.e2e.ts @@ -6,6 +6,7 @@ import { createPage, insertNotchModule, login, + openExplorerTab, openSiteEditor, publishDraft, saveDraft, @@ -156,7 +157,7 @@ test.describe('media', () => { await insertNotchModule(page, 'image') await expect(page.getByTestId('property-control-src')).toBeVisible() - await page.getByRole('button', { name: 'Open Media panel' }).click() + await openExplorerTab(page, 'Media') const mediaPanel = page.getByTestId('media-explorer-panel') await expect(mediaPanel).toBeVisible() diff --git a/tests/e2e/runtime-dependencies.e2e.ts b/tests/e2e/runtime-dependencies.e2e.ts index ff9631ea..ee38553c 100644 --- a/tests/e2e/runtime-dependencies.e2e.ts +++ b/tests/e2e/runtime-dependencies.e2e.ts @@ -6,8 +6,8 @@ import { PUBLIC_BASE_URL, createPage, login, + openCodePanel, openSiteEditor, - openSitePanel, publishDraft, saveDraft, } from './helpers' @@ -120,7 +120,7 @@ async function createRuntimeScript( source: string, expectedImport = 'canvas-confetti', ): Promise { - await openSitePanel(page) + await openCodePanel(page) const newScript = page.getByRole('button', { name: 'New script', exact: true }) await newScript.scrollIntoViewIfNeeded() await newScript.click() diff --git a/tests/e2e/site-files.e2e.ts b/tests/e2e/site-files.e2e.ts index a1ab64c2..a7fd7bbb 100644 --- a/tests/e2e/site-files.e2e.ts +++ b/tests/e2e/site-files.e2e.ts @@ -4,8 +4,8 @@ import { PUBLIC_BASE_URL, createPage, login, + openCodePanel, openSiteEditor, - openSitePanel, publishDraft, saveDraft, } from './helpers' @@ -53,7 +53,7 @@ html body { }) async function createStylesheet(page: Page, name: string, css: string): Promise { - await openSitePanel(page) + await openCodePanel(page) const newStylesheet = page.getByRole('button', { name: 'New stylesheet', exact: true }) await newStylesheet.scrollIntoViewIfNeeded() await newStylesheet.click() @@ -74,4 +74,5 @@ async function createStylesheet(page: Page, name: string, css: string): Promise< // CodeMirror syncs into the editor store through a short debounce. await expect(editor).toContainText('background-color') + await page.waitForTimeout(300) } diff --git a/tests/e2e/visual-builder.e2e.ts b/tests/e2e/visual-builder.e2e.ts index e90433ce..ec37b298 100644 --- a/tests/e2e/visual-builder.e2e.ts +++ b/tests/e2e/visual-builder.e2e.ts @@ -79,6 +79,10 @@ test.describe('visual builder', () => { 'aria-pressed', 'true', ) + // Moving off the view toggle dismisses its tooltip so Escape reaches the + // picker dialog instead of being capture-consumed by the tooltip. + await page.mouse.move(0, 0) + await expect(page.getByRole('tooltip')).toHaveCount(0) await page.keyboard.press('Escape') await expect(dialog).toBeHidden() @@ -502,6 +506,15 @@ test.describe('visual builder', () => { const postSlug = `bound-template-post-${suffix}` const bodyText = `Body rendered through SITE-018 ${suffix}` + await test.step('publish the post used as the explicit template preview source', async () => { + await createPostDraft(page, postTitle, postSlug, bodyText) + await page.getByRole('button', { name: 'Publish post' }).click() + await completeStepUp(page) + await expect( + page.getByRole('button', { name: 'Published', exact: true }), + ).toBeDisabled({ timeout: 20_000 }) + }) + await test.step('create a high-priority Posts template from the Site panel', async () => { await openSiteEditor(page) await openSitePanel(page) @@ -514,7 +527,7 @@ test.describe('visual builder', () => { await dialog.getByLabel('Applies to').click() await page.getByRole('option', { name: 'Post types' }).click() await dialog.getByLabel('Posts').setChecked(true) - await dialog.getByLabel('Priority').fill('200') + await dialog.getByLabel('Priority').fill('300') await dialog.getByRole('button', { name: 'Save' }).click() await expect(dialog).toBeHidden() @@ -522,6 +535,12 @@ test.describe('visual builder', () => { }) await test.step('insert title and body bindings into the template canvas', async () => { + const previewSource = page.getByLabel('Preview source') + await expect(previewSource).toBeVisible({ timeout: 20_000 }) + await previewSource.click() + await page.getByRole('option', { name: postTitle, exact: true }).click() + await expect(previewSource).toHaveValue(postTitle) + await insertNotchModule(page, 'text') await setPropValue(page, 'text', 'Template headline:') await page.getByRole('button', { name: 'Insert binding for Text' }).click() @@ -533,13 +552,11 @@ test.describe('visual builder', () => { await expect(page.locator('#ctrl-text')).toHaveValue('Template headline: {currentEntry.title}') await expect( - canvasFrame(page).getByText('Template headline: Example Post Title', { exact: true }), + canvasFrame(page).getByText(`Template headline: ${postTitle}`, { exact: true }), ).toBeVisible() await insertModuleViaPicker(page, 'base.outlet') - await expect( - canvasFrame(page).getByRole('heading', { name: 'Example heading' }), - ).toBeVisible() + await expect(canvasFrame(page).getByText(bodyText, { exact: true })).toBeVisible() }) await test.step('save and publish the template snapshot', async () => { @@ -547,15 +564,6 @@ test.describe('visual builder', () => { await publishDraft(page) }) - await test.step('publish a post that should render through the authored template', async () => { - await createPostDraft(page, postTitle, postSlug, bodyText) - await page.getByRole('button', { name: 'Publish post' }).click() - await completeStepUp(page) - await expect( - page.getByRole('button', { name: 'Published', exact: true }), - ).toBeDisabled({ timeout: 20_000 }) - }) - await visitPublicPage(browser, { path: `/posts/${postSlug}`, visibleText: ['Template headline:', postTitle, bodyText], @@ -1301,9 +1309,15 @@ async function createPostDraft( ): Promise { await page.goto('/admin/content') + const previousRow = new URL(page.url()).searchParams.get('row') const newPost = page.getByRole('button', { name: 'New post', exact: true }) await expect(newPost).toBeEnabled() await newPost.click() + await page.waitForURL((url) => { + const nextRow = url.searchParams.get('row') + return nextRow !== null && nextRow !== previousRow + }) + await expect(page.getByRole('textbox', { name: 'Title', exact: true })).toHaveValue('') await page.getByRole('textbox', { name: 'Title', exact: true }).fill(title) await page.getByRole('textbox', { name: 'Slug' }).fill(slug) @@ -1311,7 +1325,12 @@ async function createPostDraft( await page.keyboard.type(body) await page.getByRole('button', { name: 'More publishing actions' }).click() + const saveResponse = page.waitForResponse((response) => + /\/admin\/api\/cms\/data\/rows\/[^/]+$/.test(new URL(response.url()).pathname) && + response.request().method() === 'PATCH', + ) await page.getByTestId('toolbar-content-save-draft-action').click() + expect((await saveResponse).ok()).toBe(true) await expect(page.getByRole('button').filter({ hasText: title })).toBeVisible({ timeout: 20_000, })