252 lines
20 KiB
Markdown
252 lines
20 KiB
Markdown
# QA: Activity screen, details slide-over and My activity
|
||
|
||
## Implementation note for coder (informational, not a QA assertion)
|
||
|
||
The activity API is already built and covered by features/activity-log-backend.qa.md and
|
||
features/activity-log-producers.qa.md: this slice has no Go change and no HTTP-level Go section.
|
||
|
||
The shell specs that list the navigation are amended to seven destinations (Home, Articles, Media,
|
||
Users, Activity, Integration, Settings): features/dashboard_shell_branding.feature,
|
||
features/media_browse.feature, features/users_screen.feature and .qa.md,
|
||
features/settings_page.feature and .qa.md, features/profile_page.feature. Their Vitest tests
|
||
(DashboardShell.spec.ts, where Settings moves from index 5 to 6) must be updated in the same change.
|
||
|
||
Export needs the response body as a file, which `useApi().request` does not return (it calls
|
||
`response.json()`). Add a raw or blob read to `useApi` that keeps the global 401 handling, builds
|
||
an object URL and clicks a temporary anchor with `download="activity.csv"`. The toasts use
|
||
`useToast().add({ color, icon, title })` with the one sentence as the title.
|
||
|
||
The page needs a date-range popover and a Custom date pair in the mobile sheet. The clamp ("Show
|
||
more") is measured from the rendered element, so tests stub `scrollHeight` and `clientHeight`.
|
||
|
||
Every new English string needs a French entry in `web/src/i18n/messages.ts`. Sentences are
|
||
composed by a composable (`useActivitySentence`) that returns segments so the target name can be
|
||
rendered semibold. The Security sub-line is "{n} flagged" with n = `summary.security`, French
|
||
"{n} signalés".
|
||
|
||
## Automated (Vitest + Vue Test Utils, `task test:web`)
|
||
|
||
Mock `useApi`'s `request` (and the export transport) per case, matching on method, path and parsed
|
||
query (`URLSearchParams`); no real backend. Mount ActivityView inside DashboardShell with a
|
||
resolved verified session (id 2, username "ines", display_name "Inès Duarte", role "owner") and a
|
||
test router at /activity; the regular-user cases use role "editor" (and one with no role). Run
|
||
each layout case at a desktop and a below-md width (mock `matchMedia`, see
|
||
`components/__tests__/viewport.ts`). Timers: use `vi.useFakeTimers({ toFake: ['setTimeout',
|
||
'clearTimeout'] })` so the 150 ms skeleton delay and the 300 ms search debounce can be advanced
|
||
while `Date` stays real. No test freezes or asserts the system date.
|
||
|
||
Clock-independence rules for fixtures: every event in the Vitest fixtures is dated 3 Oct 2026 or
|
||
earlier, created at 12:00 to 13:00 UTC so its local day is the same in any time zone from UTC-11
|
||
to UTC+11; assert day separators with the absolute label "3 Oct 2026" and When cells only against
|
||
the pattern `^\d{2}:\d{2}$` and a non-empty `title`; never assert "Today", "Yesterday" or a
|
||
relative "N min ago". UTC lines can be asserted exactly; the local time line only with its
|
||
"(your time)" suffix.
|
||
|
||
Fixtures: `activityItems` (a 15-item list built from the board's events, mapped to the API shape
|
||
with the action codes of the "Visual" section), `summaryDefault` (total 1284, previous_total
|
||
1146, active_people {users 7, api_keys 2}, failed 6, security 11, series total [4,6,5,8,7,9,6],
|
||
active [5,6,6,7,7,8,9], failed [0,1,0,2,1,0,2], security [1,0,2,1,3,2,2]), `summaryZero` (total
|
||
214, previous_total 223, active_people {4, 1}, failed 0, security 0, series failed and security
|
||
zeros) and `summaryNone` (all zeros). The list response is `{ items, total, page, size }`.
|
||
|
||
1. **Navigation** — sidebar and drawer list Home, Articles, Media, Users, Activity, Integration,
|
||
Settings in order; "Activity" has the `i-lucide-history` icon, links to /activity, has
|
||
aria-current="page" on /activity and none elsewhere; breadcrumb and mobile title "Activity".
|
||
2. **Default (owner)** — assert header texts, enabled "Export", the four strip cards (labels,
|
||
"1,284", "9", "6", "11", sub-lines "12% vs previous 7 days" with the trending-up icon, "7
|
||
people, 2 API keys", "0.5% of all actions" with icon `circle-x`, "11 flagged" with icon
|
||
`shield-alert`), the filter bar (placeholder, "All people", "All categories", Outcome All
|
||
selected, 7 days selected), no chip, the pager text "Showing 1–25 of 1,284", and that `request`
|
||
was called exactly twice at mount with the expected queries (`range=7d&page=1&size=25` and
|
||
`range=7d`).
|
||
3. **Rows, Examples-driven** — one fixture row per Examples row of the feature; assert the
|
||
Actor cell (initials, key or cog or user-round-x icon, badge, "Not signed in"), the sentence
|
||
text and that the target name is in a semibold element, the shield marker only on security
|
||
rows, and the Outcome badge word.
|
||
4. **Target cell** — deleted (struck-through class, "Deleted" badge, type line), living (link
|
||
with `href` "/article/8/edit", "/media", "/users", "/integration/api-keys" per type, plain text
|
||
for "release"), and null ("—"); clicking the link navigates and does not open the details.
|
||
5. **Day grouping and When cell** — fixture across 3 Oct and 2 Oct 2026; assert two separators
|
||
"3 Oct 2026" and "2 Oct 2026" in order, the When cell pattern and `title`.
|
||
6. **Sentences, Examples-driven** — for each Examples row set the locale (`setLocale`), render
|
||
and assert the exact sentence, including plurals, `failed_groups`, "Forever", the dropped slot
|
||
for a missing target name, the unknown code `article.archive`, and the success sentence used
|
||
for an unlisted failed action (`auth.login` failed). Add a completeness test: every action in
|
||
the catalogue has an English and a French template, and every key used on the page exists in
|
||
`messages.ts`.
|
||
7. **Loading** — unresolved requests; at 149 ms nothing in place of strip and table; at 150 ms 4
|
||
skeleton cards, 9 skeleton rows (5 cards below md), `aria-busy="true"`, no pager; resolve and
|
||
assert the Default content.
|
||
8. **Zero-value strip** — `summaryZero`; assert "214" "4% vs previous 7 days" with the
|
||
trending-down icon, "5" "4 people, 1 API key", "0" "Nothing failed" and "0" "Nothing to flag"
|
||
with `circle-check`, no error or warning fill class, bars at 2 px.
|
||
9. **Total sub-line without a comparison, Examples-driven** — previous_total 0 gives "No earlier
|
||
data" and equal totals give "No change", both without a trend icon; French "Aucune donnée
|
||
antérieure" and "Aucun changement"; ranges Today, 30 days and Custom give "vs previous day",
|
||
"vs previous 30 days" and "vs previous period".
|
||
10. **Empty, no activity yet** — `summaryNone`, items []; assert "No data yet" on two cards, the
|
||
calm lines on the other two, the empty panel copy and icon, a disabled "Export", no table
|
||
header and no pager.
|
||
11. **Empty, filters match nothing** — type "sitemap", choose Category "Media" and Outcome
|
||
"Failed", resolve items []; assert chips, empty panel copy, "Clear filters", enabled export;
|
||
click "Clear filters" and assert a request with `range=7d&page=1&size=25` only.
|
||
12. **Error, Examples-driven** — list rejects with `ApiError(500)`, `ApiError(400)`, then
|
||
`NetworkError`, summary resolves; assert the strip, the alert copy, "Try again", a disabled
|
||
export and no table; click "Try again"; assert the list request is repeated and the Default
|
||
rows replace the alert.
|
||
13. **Summary alone fails** — summary rejects with `ApiError(500)`, `ApiError(403)`, then
|
||
`NetworkError`; assert the strip is absent, nothing is drawn in its place, and the list is
|
||
fully rendered.
|
||
14. **Forbidden** — list rejects with `ApiError(403)` and a verified session; assert no export
|
||
button, no strip, no filter bar, the lock panel copy, and that "Back to home" navigates to
|
||
/home. Also `ApiError(401)` triggers the global redirect and no panel.
|
||
15. **Strip cards, Examples-driven** — click each card; assert the effect and `aria-pressed`
|
||
(Failed: request with `outcome=failed&page=1`, chip "Outcome: Failed"; Security: request with
|
||
`security=true`, chip "Security events"; Total: filters cleared; Active people: no request)
|
||
and that the summary is not requested again.
|
||
16. **Filtered, card applied** — Category "Authentication", 30 days, then the Failed card; assert
|
||
the chip texts and order, "Clear all", the summary requested only once for `range=30d`, the
|
||
pager "Showing 1–6 of 6", and below md the Filters badge "3".
|
||
17. **Filter bar, Examples-driven** — for each Examples row assert the exact query, `page=1` after
|
||
starting on page 3, and the 300 ms debounce with fake timers (no request at 299 ms, one at
|
||
300 ms; Enter applies at once and the pending debounce does not send a second request).
|
||
18. **Custom dates** — pick two dates in the popover (stub the calendar's model); assert no request
|
||
after the first date, then `range=custom&from=2026-09-01&to=2026-09-30` on the list and the
|
||
summary, and the chip "Dates: 1 – 30 Sep 2026"; also "28 Sep – 3 Oct 2026".
|
||
19. **Chips** — remove one chip, then "Clear all"; assert the queries and that the summary is
|
||
requested again only when the range changes back.
|
||
20. **Person options** — assert no `GET /v1/user/` or `GET /v1/api-key/` call at mount, both on
|
||
first open of the menu, none on the second; assert group order and the three query values.
|
||
21. **Pagination** — next page, size 50, last page "Showing 1,276–1,284 of 1,284" with next
|
||
disabled; mobile "Page 1 of 52" and previous and next.
|
||
22. **Mobile list and sheet** — below md assert cards (actor line, sentence, badges, "Target
|
||
deleted"), "Filters" with the badge count, the sheet content and footer ("Show 6 results", "Show
|
||
1 result"), two date fields for Custom, and that "Show … results" closes it.
|
||
23. **Export** — superseded by activity_filter_cards_and_export.qa.md (menu, dialog, toasts).
|
||
24. **Export failure** — superseded by activity_filter_cards_and_export.qa.md.
|
||
25. **Details open and close** — click, then Enter on a focused row; assert the title, the selected
|
||
row class, the footer "Event 2 of 1,284"; close with Escape, the close button and the scrim and
|
||
assert focus on the row.
|
||
26. **Details content, Examples-driven** — the six board events: Edit with a diff (Title row with
|
||
both values and the "Body edited (+312 characters)" row, "(+1 character)", "(-48 characters)"),
|
||
long text (stub overflow; "Show more" on that row only, toggles to "Show less"), failed with a
|
||
long title (alert "Why it failed", wrapping), deleted target (struck-through name, "Deleted"
|
||
badge, explanation, no Changes), system event ("System" badge, "System task", two "Not
|
||
applicable"), failed login ("Not signed in", "Security" badge, reason, IP and Browser). Assert
|
||
the field labels and the Draft and Published values for `online`, the raw code for an unknown
|
||
field, and that no body text is ever rendered.
|
||
27. **Details times** — `created_at` "2026-10-06T10:42:18Z": the UTC line is exactly "6 Oct 2026,
|
||
10:42:18 UTC" and the local line ends with "(your time)".
|
||
28. **Previous and Next across pages** — first item: previous disabled, "Event 1 of 1,284"; 24
|
||
nexts without a request; the 25th next sends `page=2&size=25` with the same filters, shows
|
||
page 2's first item, "Event 26 of 1,284" and the pager "Showing 26–50 of 1,284"; previous
|
||
sends `page=1` and shows "Event 25 of 1,284"; the last event disables next; a 500 on the
|
||
neighbour page keeps the panel on the current event and re-enables the button; the position
|
||
also follows a non-default size (size 50, page 2, index 0 is "Event 51").
|
||
29. **Details below md** — the bottom-sheet variant: header "2 of 1,284", full-width buttons, no
|
||
footer text.
|
||
30. **My activity, Examples-driven** — role "editor" then no role: title, subtitle, breadcrumb "My
|
||
activity", no strip, no Person filter, no export, no Actor column or card actor line; assert no
|
||
request to `/summary` or `/export` and no `actor` parameter; pager "Showing 1–25 of 41"; empty
|
||
state copy; details "Event 1 of 41" with previous disabled.
|
||
31. **French** — `setLocale('fr')`; assert the chrome strings (Activity "Activité", My activity
|
||
"Mon activité", Export CSV "Exporter", Preparing file… "Préparation du fichier…",
|
||
Total actions "Actions au total", Active people "Personnes actives", Failed actions "Actions
|
||
en échec", Security events "Événements de sécurité", Succeeded "Réussie", Failed "Échec", Not
|
||
signed in "Non connecté", Activity details "Détails de l'activité", Previous event and Next
|
||
event "Événement précédent" and "Événement suivant", "11 signalés"), the French sentences, the
|
||
plural and "Forever" ("Toujours") forms, and the day separator format.
|
||
32. **Hover time** — each When cell has a `title` that is non-empty and contains seconds.
|
||
|
||
## Visual (task visual:check -- activity, desktop and mobile, light and dark)
|
||
|
||
Reference: `docs/design/screens/activity.md`, board `docs/design/source/RenewCMS Screens
|
||
Activity.dc.html`, groups "Activity, owner and admin", "Details slide-over" and "My activity"
|
||
(the last two are already declared as `extraSources` in `web/visual/check.ts`); exact values in
|
||
`docs/design/source/ActivityFrame.dc.html`. Add each state to `visualStates.activity` in
|
||
`web/visual/states.ts` with the board's own sample data. The gate is hard: every capture of every
|
||
non-skipped state must be within 10 %, for both viewports and both themes (6 gated states, 24
|
||
captures). No state uses the `now` option or a frozen clock. Mocks are keyed by method and path
|
||
(`GET /v1/activity`, `GET /v1/activity/summary`, `GET /v1/auth/me`); use `bodyFor` to read the
|
||
query. Do not mock or call `/v1/user/` or `/v1/api-key/` (Person options load only when the menu
|
||
opens, which no state does), so there is no unmocked request.
|
||
|
||
Sessions: add `activityOwnerSession` (id 2, display_name "Inès Duarte", role "owner") and
|
||
`activityEditorSession` (id 3, display_name "Marta Alves", role "editor"), as the board draws them
|
||
in its sidebar chip. The board draws a 4-item sidebar for the regular user as illustrative; the
|
||
real shell keeps all 7 items, a known small difference in the My activity Empty capture.
|
||
|
||
Fixtures (all taken from the frame's script): `activityBoardEvents`, the 15 events of the frame in
|
||
its order, mapped to API items. Where the frame's sample has no real producer the nearest real
|
||
code is used and the sentence differs from the board (accepted; those rows only appear in skipped
|
||
states): 1 `article.publish`, 2 `article.edit` with title and body (+312) changes, 3
|
||
`auth.login_failed` (anonymous, name "alex@example.com", reason from the frame), 4
|
||
`api_key.revoke`, 5 `system.media_purge` (system, ip and user_agent null), 6 `invite.accept`
|
||
(replaces "Changed the role"), 7 `media.upload` by the API key "Website sync", 8 `media.delete`
|
||
(target not existing), 9 `article.publish` failed with the 150-character title, 10
|
||
`system.activity_cleanup`, 11 `article.edit` with long title values (replaces the "Site
|
||
description" setting diff), 12 `api_key.create`, 13 `user.update_profile`, 14 `media.upload`, 15
|
||
`auth.password_reset_completed`; times are the frame's local times minus one hour as UTC
|
||
(`created_at`), IPs and browsers as in the frame, security as in the frame. Summary fixtures are
|
||
the three in the Automated section; the strip of the Default state is "1,284 / 9 / 6 / 11" with
|
||
the sub-line "11 flagged". Total is 1284 with page size 25 for the owner and 41 for the editor.
|
||
|
||
- [ ] Loading: both requests with `hang: true`; the setup waits 150 ms or more so the skeleton
|
||
strip and rows are drawn before the capture.
|
||
- [ ] Empty, no activity yet: list `items: [], total: 0`, summary `summaryNone`; "Export"
|
||
disabled.
|
||
- [ ] Empty, filters match nothing: the board applies Search "sitemap", Category "Media", Outcome
|
||
"Failed" (steps type and select them, or the mock answers with the filtered query), list
|
||
`items: [], total: 0`, summary Default.
|
||
- [ ] Error with retry: list 500, summary Default; the alert, disabled export, strip drawn.
|
||
- [ ] Forbidden (403): list 403, summary 403; session with a non-admin role as the board does
|
||
(`role: 'user'`), so no "Export".
|
||
- [ ] My activity, Empty: `activityEditorSession`, list `items: [], total: 0`.
|
||
- [ ] skip: clock-sensitive. Default, large dataset: `skip: 'clock-sensitive'` (day separators and
|
||
"2 min ago" depend on the real clock). Prints SKIP, never investigated or date-frozen.
|
||
- [ ] skip: clock-sensitive. Zero-value summary strip, Filtered card applied and Mobile filters
|
||
sheet (all draw Today rows behind the strip or sheet).
|
||
- [ ] skip: clock-sensitive. The six Details slide-over states (Edit with a diff, Settings diff
|
||
long text, Failed action very long target title, Deleted target no diff, System-initiated no
|
||
human actor, Security event failed login) and the My activity states Default and Details:
|
||
the rows behind the scrim show relative times and the panel shows the browser's local
|
||
time. Their Vitest coverage is cases 3, 5, 25 to 30.
|
||
- [ ] Layout, from `ActivityFrame.dc.html`, not `components.md`: header row is the title block
|
||
(20 px/28 px title) on the left and the 32 px (44 px mobile) "Export" button on the right,
|
||
aligned to the bottom; the strip is 4 columns with a 12 px gap on desktop and a clipped row
|
||
of 156 px cards with an 8 px gap on mobile; the table header is 36 px, day rows 32 px, rows at
|
||
least 52 px; the slide-over is 480 px wide on desktop, the bottom sheet 88 % high.
|
||
- [ ] Error is drawn as the frame's alert (error soft background, circle-x, title, text, 28 px
|
||
outline "Try again"), the empty states as the frame's centred panel (44 px icon box, 15 px
|
||
semibold title, muted text up to 340 px).
|
||
- [ ] Not covered by screenshots: keyboard and focus order (Tab reaches Export CSV, the strip
|
||
cards, the search, the selects, the groups, a row; Enter opens the details; focus is trapped
|
||
in the slide-over and returns to the row on close; Escape closes it); hover (the row, the
|
||
native `title` tooltip), the 240 ms slide-over and sheet motion and the skeleton pulse, all
|
||
stopped under reduced motion; the date-range popover.
|
||
|
||
## Manual verification
|
||
|
||
- **Real round trips against `task dev`** — Vitest only mocks `useApi`. As owner: perform an
|
||
article publish, an image upload and a failed login, then confirm they appear newest first with
|
||
the right sentences, the shield marker on the failed login, the Succeeded and Failed badges, the
|
||
summary numbers and the sparkline bars; apply each filter and each card; page through with size
|
||
25, 50 and 100; open the details across a page boundary.
|
||
- **Real CSV** — click "Export" with and without filters and open `activity.csv`: columns
|
||
`when,actor,action,target,outcome,source,ip`, the filtered rows only, no page or size applied,
|
||
neutralised cells that start with `=`, `+`, `-` or `@`; confirm the browser download prompt and
|
||
the two toasts.
|
||
- **Admin session** — sign in as an admin: the same full view as the owner.
|
||
- **Time zones** — set the OS time zone to UTC-8 and UTC+9, reload, and confirm the day
|
||
separators, the clock times, the "(your time)" line and the UTC line are consistent.
|
||
- **Regular user** — no real account can reach "My activity" today; confirm by a mocked
|
||
`/v1/auth/me` in the browser (or a future role) that the view has no strip, no Person filter and
|
||
no export, and that the server answers 403 for `actor=user:2` and for the summary and export.
|
||
- **API key** — calling `GET /v1/activity` with an API key answers 403 "This action needs a real
|
||
session." and the SPA never does it.
|
||
- **"Today", "Yesterday" and relative times** — confirm them against the real clock, since
|
||
visual:check skips every state that shows them.
|
||
- **French** — switch the language and read every state, the sentences and the toasts.
|
||
- **Real screen readers and zoom** — row selection, the sparkline buttons (labelled by the card
|
||
text), the sheet focus trap.
|