feat(integration): add image variants section and standardize code snippets

This commit is contained in:
Florian Sylvain committed 2026-10-12 23:27:41 +02:00
1 parent 6f9ae6207a
commit e6746f2be7
28 files changed
+2256 -422

No files matched your search

+2 -3
View File
@@ -155,7 +155,7 @@ Feature: API keys screen
"\"Nightly media import\" is ready. Copy the key and store it somewhere safe."
And a warning alert (UAlert, color warning, triangle-alert icon) reads "You won't be able to
see this key again."
And the full key from the response is shown in a monospace box with a "Copy" button (icon copy)
And the full key from the response is shown in a CodeSnippet with the single tab "API key" and a "Copy" button (icon copy, accessible name "Copy code"), focused when the modal opens
And the note "Closing this window discards the key from the screen. Copy it first." is shown
And the only button is a primary "Done"
@@ -181,8 +181,7 @@ Feature: API keys screen
Given the key reveal is open
When the user activates "Copy"
Then the full key is written to the clipboard
And the button shows icon check and text "Copied" in the success color, announced to assistive
technology
And the button shows icon check and text "Copied" in the success color, announced politely
When 2 seconds elapse
Then the button reverts to icon copy and text "Copy"
+3 -3
View File
@@ -68,8 +68,8 @@ layout cases at desktop and below-md (mock `matchMedia`).
errors clear when fixed.
9. **Create key: name too long** - 75 characters; counter, error class and text, no request.
10. **Create key: success** - one POST with `{"name":"Nightly media import"}` and no expires_at
key; the reveal: title, text, warning alert, key equal to the mocked `key`, "Copy", note, only
"Done".
key; the reveal: title, text, warning alert, key equal to the mocked `key` inside a CodeSnippet with
the single tab "API key", "Copy" (named "Copy code") focused on open, note, only "Done".
11. **Create key: expiry** - date D + 30 days; assert expires_at is 23:59:59 local on that day
with the local offset. Examples today (sent), yesterday (blocked), tomorrow (sent); build the
dates from `new Date()` in the test.
@@ -137,7 +137,7 @@ States:
- [ ] Create key
- [ ] Create key: validation
- [ ] Create key: name too long
- [ ] One-time reveal
- [ ] One-time reveal (re-run after the CodeSnippet migration, within 10 %)
- [ ] One-time reveal: copied (timer-driven, so it is checked)
- [ ] Revoke: confirm
- [ ] Revoked (list with the revoked row and the toast)
+14 -12
View File
@@ -7,7 +7,7 @@ Feature: Integration screen (API reference)
Background:
Given /integration is a verified route (meta.auth "verified") rendered as IntegrationPage
inside DashboardShell, per docs/design/routes.md
And the screen makes no /v1 calls; api: none (static)
And the screen makes one call, GET /v1/image-variants, for owner and admin sessions only (features/integration_images.feature)
And the PageHeader shows breadcrumb "Integration", title "API reference" and description
"Everything a developer needs to build on RenewCMS."
And the endpoint catalogue is a typed static data file at web/src/data/apiCatalogue.ts, and the view only renders it
@@ -37,7 +37,7 @@ Feature: Integration screen (API reference)
Scenario: Default, every card collapsed
When the user navigates to /integration
Then the intro reads "RenewCMS is headless: it stores your content and your own code displays it. There are two ways in. Public read-only access lets a website read online articles. API keys let a script or service manage articles, media and users without the back-office."
And a "Base URL" row shows window.location.origin in a monospace box with a "Copy" button (icon copy)
And a "Base URL" label sits above a CodeSnippet with the single tab "Base URL" showing window.location.origin and a "Copy" button (icon copy)
And every EndpointCard is collapsed as a 44 px row: method badge, mono path, one-line description, chips, chevron
And the sticky section index lists the sections on desktop and a "Jump to" select lists them on mobile
@@ -70,7 +70,7 @@ Feature: Integration screen (API reference)
| Name | Type | Required | Rule |
| title | string | Yes | 3 to 200 characters. Can't be changed later. |
| body | string | No | No length limit. |
And tabs fetch, curl and Response show, fetch active, with a CodeBlock labelled "JavaScript" and a ghost "Copy" button
And one CodeSnippet with the tabs fetch, curl and Response shows, fetch active, with a "Copy" button
And an "Errors" list shows a status chip with a one-line caption for 400, 401
Scenario: Query parameters table, new UI beyond the board, user-approved
@@ -114,7 +114,7 @@ Feature: Integration screen (API reference)
Scenario: Authentication section
Then card "Public" reads "No key needed. Two read-only routes, and only online articles are returned." and names GET /v1/article/ and GET /v1/article/{id}
And card "API key" reads "Send `Authorization: Bearer rcms_…`. Requests act as the user who created the key, with that user's role (owner or admin). A key never has more rights than its creator."
And its CodeBlock has tabs curl and fetch for GET /v1/auth/me, using the current origin
And its CodeSnippet has tabs curl and fetch for GET /v1/auth/me, using the current origin
And it says "Keys start with `rcms_` and are shown in full once, when you create them."
And a "Manage API keys" link navigates to /integration/api-keys
And Note 1 reads "A key can't create, list or revoke API keys, verify or cancel a registration, or call anything under `/v1/system`. Those routes answer 403 "This action needs a real session." An invalid, unknown, expired or revoked key answers 401, even on public routes, and so does a key whose user was deleted or unverified."
@@ -139,7 +139,7 @@ Feature: Integration screen (API reference)
| 409 | There's already a pending invite for this address. / This invite has already been accepted. |
| 429 | Too many requests. Please try again later. |
| 500 | Internal server error |
And a sample shows "HTTP/1.1 403 Forbidden", "Content-Type: text/plain", a blank line, and "This action needs a real session."
And a CodeSnippet with the single tab "Error response · text/plain" shows "HTTP/1.1 403 Forbidden", "Content-Type: text/plain", a blank line, and "This action needs a real session."
And the table does not use the SPA copy of docs/design/states.md
Scenario Outline: Per-endpoint error captions use the real strings
@@ -169,15 +169,15 @@ Feature: Integration screen (API reference)
Given the app is served from origin "{origin}"
When the user activates the Base URL "Copy" button
Then "{origin}" is written to the clipboard
And the button shows icon check and "Copied", announced to assistive technology
And the button, named "Copy code", shows icon check and "Copied", announced politely
When 2 seconds elapse
Then the button reverts to icon copy and "Copy"
Scenario: Copied, CodeBlock is independent of Base URL
Scenario: Copied, CodeSnippet is independent of Base URL
Given a card is expanded
When the user activates that CodeBlock's "Copy JavaScript example" button
Then that card's exact code string is written to the clipboard and only that button reads "Copied"
And the Base URL button and other CodeBlocks are unaffected
When the user activates that CodeSnippet's "Copy code" button
Then the active tab's exact code string is written to the clipboard and only that button reads "Copied"
And the Base URL button and other CodeSnippets are unaffected
Scenario: Examples use the current origin and the API key header
Then every fetch and curl example starts from "{origin}" and carries no ?format= query
@@ -189,5 +189,7 @@ Feature: Integration screen (API reference)
Then the page scrolls to that anchor and the item reports aria-current "true"
And on mobile the "Jump to" select opens and lists the same sections
Scenario: The page stays static
Then no /v1 request is made while the page renders or while any card is expanded
Scenario: Only the image variants calls
Then the only /v1 request is GET /v1/image-variants, sent for owner and admin sessions on mount, on "Try again" and on window focus
And expanding or collapsing any card makes no /v1 request
And a session that is neither owner nor admin makes no /v1 request
+7 -6
View File
@@ -4,7 +4,7 @@
Mount IntegrationPage inside DashboardShell with a resolved verified session and a test router at
/integration. Stub `window.location.origin` to `https://cms.renew.example` (the board's sample) and
`navigator.clipboard.writeText` per case. No `/v1` mocks are needed. A spec fails if any `/v1` call
`navigator.clipboard.writeText` per case. Mock only GET /v1/image-variants (see integration_images.qa.md). A spec fails if any other `/v1` call
is made.
Catalogue spec (pure data, no mount, imports web/src/data/apiCatalogue.ts):
@@ -44,12 +44,12 @@ Component specs, one per design state:
invite rows show "Owner only".
2. **Group scrolled, one card expanded**: click the POST /v1/article/ row. Assert the Query
parameters table above the Request body table, the body rows, tabs fetch/curl/Response with fetch
active, the "JavaScript" CodeBlock, and the Errors list with 400 and 401.
active in one CodeSnippet, and the Errors list with 400 and 401.
3. **Copied: base URL**: fake timers; click Copy. Clipboard gets the origin; check icon and "Copied";
announced; reverts after 2 s.
4. **Copied: CodeBlock**: expand a card; click "Copy JavaScript example". Clipboard gets that code,
4. **Copied: CodeSnippet**: expand a card; click "Copy code". Clipboard gets the active tab's code,
only that button reads "Copied", and it reverts after 2 s.
5. **Authentication**: both cards, curl/fetch tabs for GET /v1/auth/me, the "Manage API keys" link
5. **Authentication**: both cards, a CodeSnippet with curl/fetch tabs for GET /v1/auth/me, the "Manage API keys" link
href /integration/api-keys, Note 1, Note 2.
6. **Session only**: the corrected banner; lock icon and "Session only" chip only on rows that
answer 403; the caption on the login family; no expand control; the "Public auth flow" rows with
@@ -61,6 +61,7 @@ Component specs, one per design state:
9. **Tabs**: curl and Response render for a card. curl contains the stubbed origin and the
Authorization header only on non-public routes. Not visually checked.
10. **Notes**: format note, image-prefix note, CORS note.
11. **Base URL and error sample**: both are CodeSnippets (single tabs "Base URL" and "Error response · text/plain").
## Visual (`task visual:check -- integration`, desktop and mobile, light and dark)
@@ -78,7 +79,7 @@ diff, sets the smallest `maxPercent` that is sufficient, writes the reason, and
- [ ] Default (exception: owner-only chips and rate-limit line)
- [ ] Group scrolled, one card expanded (exception: Query parameters table)
- [ ] Copied: base URL (must pass 10 %)
- [ ] Copied: CodeBlock (must pass 10 %)
- [ ] Copied: CodeSnippet (must pass 10 %)
- [ ] Authentication (must pass 10 %; Note 1 text changed)
- [ ] Session only (exception: banner, 13th row, captions, Public auth flow block)
- [ ] Errors (exception: 409 row)
@@ -88,7 +89,7 @@ None of these states is clock-sensitive, so none is skipped.
## Manual
- Keyboard and focus order: index, Base URL Copy, each row (Enter/Space), tab strip (arrow keys),
CodeBlock Copy, the pre (tabindex 0), Jump to on mobile.
CodeSnippet Copy, the pre (tabindex 0), Jump to on mobile.
- Hover and motion on ghost Copy buttons and rows.
- Find-in-page and a deep link to an `#ep-...` anchor.
- Real origin in a production build and under `task dev`.
+305
View File
@@ -0,0 +1,305 @@
Feature: Integration: images and variants, and CodeSnippet as the only code block
As a developer building a site on RenewCMS
I want the Integration page to explain image variants and give me copyable, correct URLs
So that I can request the right image size without reading the source
Background:
Given the spec of record is docs/design/screens/integration.md ("Integration: images and ?v="),
docs/design/screens/integration-api-keys.md, docs/design/components.md (CodeSnippet), the boards
docs/design/source/IntegrationImages.dc.html and CodeSnippet.dc.html, and docs/design/routes.md
And this slice is SPA only: no Go change, and the image API is the one of features/image-variants.feature
And /integration is a verified route rendered as IntegrationPage inside DashboardShell, per
features/integration.feature
And the section "Images and variants" has the anchor id "images", sits where the board draws it (after the API groups and before "Session only" and "Errors"), and has an entry "Images and variants" in the section index and the "Jump to" select
And the section index and the "Jump to" select list the sections in page order, so the "Images and variants" entry sits right before "Session only"
And the board and its own index disagree on this position, which is logged for the design pass
And the section title is an h2 "Images and variants" (18/26) with sub headings h3 (14/22)
And the section intro reads "Every image in the media library can be requested as a named variant. Add the
?v=<variant-name> parameter to the image URL. The original file is never changed, and a variant is generated the
first time it is requested. Variants are managed in Settings, Media, Variants."
And the section calls, with a relative URL through useApi only, GET /v1/image-variants, an owner/admin-only route
And an item has the fields name, width, height, fit, format, quality, generate_on_upload, enabled,
generated_files and cache_size, and items arrive in name order
And only items with enabled true are "enabled variants"; the order of the API response is kept
And "first variant" below means the first enabled variant, "second" and "third" the next ones, each falling back to the first when absent
And {origin} is window.location.origin and {image} is the builder image, "north-wall.jpg", a fixed sample with no media lookup
And a variant URL is "{origin}/media/{image}?v={name}"
And every visible string goes through t(...), with the English text as the key and its French entry in
web/src/i18n/messages.ts
And the section uses Nuxt UI components and design tokens only, with no raw colour
And the list is read through the useImageVariantsStore
And the board sample data is {origin} "https://cms.renew.example", image "north-wall.jpg" and these variants, in this order:
| name | width | height | format |
| thumb | 400 | 300 | webp |
| card | 800 | null | webp |
| hero | 1200 | 630 | webp |
| square | 600 | 600 | jpeg |
| og | 1200 | 630 | jpeg |
| banner | 1600 | 400 | webp |
| teaser | 640 | 360 | webp |
| sidebar | 320 | null | webp |
| gallery | 1000 | 750 | webp |
| avatar | 96 | 96 | webp |
| wide | 1920 | 800 | webp |
| mini | 120 | 90 | webp |
Rule: CodeSnippet is the only code block
Scenario: CodeSnippet anatomy
Given a CodeSnippet with the tabs curl, HTML and JavaScript
Then a header row at least 44 px high holds the tab list (aria-label "Language") on the left and the Copy button on the right
And the selected tab has text --ui-text-highlighted, weight 500 and a 2 px --ui-primary underline
And unselected tabs have --ui-text-toned and weight 400
And below it a pre with tabindex 0 shows the active tab's code in IBM Plex Mono 12/19, scrolls horizontally and has no syntax colouring
And the background is --ui-bg-elevated with a --ui-border border and no corner radius
And the Copy button is at least 44 px wide, reads "Copy" with icon copy and has the accessible name "Copy code"
And each tab and the Copy button show a 2 px --ui-primary focus ring when focused with the keyboard
Scenario: A CodeSnippet with one tab shows a plain label
Given a CodeSnippet with a single tab "Base URL"
Then the label is plain --ui-text-muted text, not a tab, with no underline
Scenario: Copy writes the active tab and reads Copied for 2 s
Given a CodeSnippet with the tabs curl, HTML and JavaScript and the JavaScript tab active
When the user activates "Copy"
Then the JavaScript code is written to the clipboard
And the button shows icon check and "Copied" in --ui-success
And the text lives in an aria-live "polite" region, so "Copied" is announced politely
When 2 seconds elapse
Then the button reads "Copy" with icon copy again
Scenario: Two CodeSnippets copy independently
Given the page shows several CodeSnippets
When the user activates "Copy" on one of them
Then only that one reads "Copied"
Scenario Outline: Every code block of the product is a CodeSnippet
Then <place> renders as a CodeSnippet with <tabs>
And no CodeBlock, no shiki highlighted block and no hand-rolled copy button remains there
Examples:
| place | tabs |
| Base URL on /integration (label "Base URL" above it) | one tab "Base URL" with {origin} |
| the Authentication "API key" example | tabs curl and fetch, GET /v1/auth/me |
| an expanded EndpointCard | tabs fetch, curl and Response, fetch active |
| the Errors section sample | one tab "Error response · text/plain" |
| the "Request an image" snippet | tabs curl, HTML and JavaScript |
| the URL builder output | one tab "URL" |
| the one-time key of ApiKeyReveal on /integration/api-keys | one tab "API key" |
Scenario: The old component is removed
Then web/src/components/CodeBlock.vue no longer exists and nothing imports it, including EndpointCard.vue and IntegrationView.vue
And web/src/components/CodeText.vue stays, because it renders inline code in prose and not a code block
And the shiki highlighter helpers and styles used only by CodeBlock are removed when nothing else uses them
And the hand-rolled Base URL box, the error sample pre and the key box of ApiKeyReveal are gone
Scenario: The one-time key reveal keeps its behaviour
Given the ApiKeyReveal dialog opens after POST /v1/api-key answers 201
Then the key is shown in a CodeSnippet with one tab "API key" and "Copy"
And the Copy button receives focus when the dialog opens, as before
And activating it writes the full key to the clipboard and reads "Copied" for 2 s, announced politely
And the warning "You won't be able to see this key again." and the text "Closing this window discards the key from the screen. Copy it first." stay unchanged
Rule: The variants list is reloaded
Scenario: Reload triggers
Given the session role is "owner" or "admin"
Then GET /v1/image-variants is sent on mount
And it is sent again on "Try again"
And it is sent again on every window focus event while /integration is shown
And a reload keeps the active snippet tab and the builder selection when the selected variant is still enabled
Scenario: A non-admin never calls the API
Given the session role is neither "owner" nor "admin"
Then no request to GET /v1/image-variants is sent on mount, on focus or on any other trigger
Rule: Section content
Background:
Given the session role is "owner"
Scenario: I1. Many variants
Given GET /v1/image-variants answers 200 with the twelve sample variants enabled and "print" (disabled)
When the user opens /integration#images
Then the h3 "Request an image" shows a CodeSnippet with tabs "curl", "HTML", "JavaScript", with HTML active
And the h3 "Variants" shows "12 enabled variants"
And the table has the columns Name, Dimensions, Format and Example URL and 12 rows, in API order
And the "thumb" row reads "thumb", "400×300", "WebP", "/media/north-wall.jpg?v=thumb"
And the "card" row reads "800 × auto"
And the table body scrolls vertically at 264 px, because there are more than 6 rows
And the note reads "Disabled variants are not served. A request for one returns 400 "Unknown variant"."
And "print" is not a row
And the h3 "URL builder" shows the Image, Variant (default "thumb"), URL (CodeSnippet) and Preview
And the h3 "Responses" shows the responses table
Scenario: The disabled-variants note matches the backend
Given a variant "print" is disabled
When /media/north-wall.jpg?v=print is requested
Then the backend answers 400 application/json with {"status":400,"error":"unknown_variant","message":"Unknown variant","detail":"print"} and does not fall back to the original
And the note is shown only when the response contains at least one disabled variant
Scenario: The snippets use the first variants
Given the sample variants
Then the curl tab reads: curl -O "https://cms.renew.example/media/north-wall.jpg?v=thumb"
And the HTML tab reads:
"""
<img
src="https://cms.renew.example/media/north-wall.jpg?v=card"
srcset="https://cms.renew.example/media/north-wall.jpg?v=thumb 400w,
https://cms.renew.example/media/north-wall.jpg?v=card 800w,
https://cms.renew.example/media/north-wall.jpg?v=hero 1200w"
sizes="(min-width: 800px) 800px, 100vw"
alt="North wall">
"""
And the JavaScript tab reads:
"""
const res = await fetch("https://cms.renew.example/media/north-wall.jpg?v=thumb")
if (!res.ok) throw new Error(await res.text())
const blob = await res.blob()
"""
And the srcset widths 400w, 800w and 1200w are sample values, not read from the variants
Scenario: Tables and snippet scroll on narrow screens
Given a viewport of 390 px
Then the variants table keeps a minimum width of 520 px and scrolls horizontally inside its border
And the Responses table keeps a minimum width of 560 px and scrolls horizontally
And the CodeSnippet body scrolls horizontally and the page itself does not
Scenario: I2. One variant
Given GET /v1/image-variants answers 200 with only "thumb" enabled
Then the Variants heading reads "1 enabled variant" and the table has one row without a vertical scroll limit
And the three snippets use "thumb" for the first, second and third variant
And the "Disabled variants are not served" note is not shown
Scenario: I3. No variants, admin
Given the session role is "admin"
And GET /v1/image-variants answers 200 with {"items": []} or only disabled variants
Then an empty block with the layers icon shows "No image variants yet"
And its text reads "Until you add a variant, any request with the ?v= parameter returns status 400 unknown_variant. Requests without it return the original image."
And a primary button "Go to image variants" navigates to /settings/media/variants
And the snippets use the placeholder <variant-name>
And no table and no URL builder are drawn
And the Responses table is drawn
Scenario: I4. No variants, not an admin
Given the session role is neither "owner" nor "admin"
Then no request to GET /v1/image-variants is sent
And the empty block shows "No image variants yet"
And its text reads "Ask an owner or admin to add one. Until you do, any request with the ?v= parameter returns status 400 unknown_variant. Requests without it return the original image."
And there is no button and no link
And the snippets use the placeholder <variant-name>
Scenario: The empty-state copy is corrected against the backend
Then the I3 and I4 texts above replace the design copy "the ?v= parameter is not accepted and every request returns the original image", which is false
And the backend returns 400 unknown_variant for any ?v= value when no variants exist, and serves the original only without ?v
And the board copy is logged as wrong for the design pass, and docs/design is not edited
Scenario: I5. Variant removed
Given the URL builder has "hero" selected
And a reload of the list returns no enabled "hero"
Then the Variant field reads "hero (removed)" and has a --ui-warning border
And the URL snippet reads "https://cms.renew.example/media/north-wall.jpg?v=hero"
And a warning alert (role alert, triangle-alert icon) reads the title "The variant "hero" no longer exists"
And its text reads "There is no fallback to the original. Requests with ?v=hero return status 400 with the body { "status": 400, "error": "unknown_variant", "message": "Unknown variant", "detail": "hero" }. Remove the parameter to get the original image, or pick another variant."
And the Preview is dimmed to 50 %, 4 / 3, and reads "No preview: unknown variant"
Scenario: A reload on window focus can trigger I5
Given the builder has "hero" selected and the list was loaded with "hero" enabled
When "hero" is disabled or deleted elsewhere and the window receives a focus event
Then GET /v1/image-variants is sent again and I5 is shown
Scenario: I6. Loading
Given GET /v1/image-variants has not answered
Then three skeleton blocks of 140, 200 and 120 px (--ui-bg-accented) fill a region with aria-busy "true"
And the "Request an image" snippet, the variants table, the URL builder and the Responses table are not drawn
And the intro paragraph is drawn
Scenario Outline: I7. Error loading
Given GET /v1/image-variants fails with <failure>
Then an error alert (role alert, circle-x icon) reads "Couldn't load image variants"
And its text reads "The snippets below still work. The variant list and the URL builder need a connection."
And a button "Try again" is shown
And the "Request an image" snippet is drawn with the placeholder <variant-name>
And no table, no URL builder and no Responses table are drawn
When the user activates "Try again"
Then GET /v1/image-variants is sent again
Examples:
| failure |
| status 500 |
| a network failure |
Scenario: A 403 on the list is handled as not an admin
Given the session role is "owner" but GET /v1/image-variants answers 403
Then I4 is shown
And a 401 is left to the global handler of features/api_client_global_error_handling.feature
Scenario: I8. Snippet copied
Given the sample variants and the HTML tab active
When the user activates "Copy" on the "Request an image" snippet
Then the HTML code is written to the clipboard
And the button reads "Copied" with icon check for 2 s, announced politely
And the URL builder's Copy button still reads "Copy"
Scenario: The URL builder
Given the sample variants
Then the Image field is a select showing "north-wall.jpg" and the Variant field is a select listing the enabled variants
When the user picks "square" in Variant
Then the URL snippet reads "https://cms.renew.example/media/north-wall.jpg?v=square"
And the Preview is a framed placeholder with aspect-ratio width / height of the variant, and 4 / 3 when the width or the height is null, shows the label "square · 600×600 · JPEG" and is announced with role img and the same aria-label
And the Preview is at most 240 px high and full width
And no image is requested from the server
Scenario: I9. URL builder copied
Given the URL builder shows "https://cms.renew.example/media/north-wall.jpg?v=thumb"
When the user activates "Copy" on the URL snippet
Then that exact URL is written to the clipboard
And the button reads "Copied" for 2 s, announced politely
And the "Request an image" snippet's button still reads "Copy"
Scenario: The Responses table
Given any state except Loading and Error loading
Then the table has the columns Request, Status and Response and these rows:
| request | status | response | body |
| /media/photo.png?v=thumb | 200 | The variant image. Generated on first request. | |
| /media/photo.png?v=unknown | 400 | Unknown variant | { "status": 400, "error": "unknown_variant", "message": "Unknown variant", "detail": "unknown" } |
| /media/photo.png?width=400 | 400 | Unsupported parameter | { "status": 400, "error": "unsupported_parameter", "message": "Unsupported parameter", "detail": "width" } |
| /media/missing.png?v=thumb | 404 | Not found | 404 page not found |
| /media/huge.png?v=thumb | 413 | Source image too large | The source image is too large to generate this variant. |
| /media/photo.png?v=thumb | 500 | Unable to read the file | Unable to read the file |
And the body of the 400 rows is application/json and monospace --ui-text-muted below the response text
And the 404 body is text/plain "404 page not found"
And the 413 and 500 bodies are text/plain
And the table has no 409 and no 422 row
And the 413 and 500 rows are added although the board lacks them, because the handler returns them, and this is logged for the design pass
Scenario: The error sample tab names its content type
Then the Errors section sample keeps its single tab "Error response · text/plain"
And the image JSON errors are covered only by the Responses table, where their bodies are application/json
And no snippet labelled text/plain shows an unknown_variant or unsupported_parameter body
Scenario: The page calls only the image variants route
When the user opens /integration as an owner or admin
Then GET /v1/image-variants is sent once on mount, plus once per "Try again" and once per window focus event
And no other /v1 call is made
And for any other role no /v1 call is made
Rule: The existing Integration surfaces use CodeSnippet
Scenario: Authentication example
Then the "API key" card shows one CodeSnippet with tabs curl and fetch for GET /v1/auth/me, curl active, using {origin}
And there is no outer UTabs wrapper and no label "Shell" or "JavaScript"
Scenario: Endpoint card examples
When the user expands a card
Then one CodeSnippet holds the tabs fetch, curl and Response, fetch active
And the tab list uses the underline style, not the UTabs link style
And the Copy button copies the active tab and its accessible name is "Copy code"
And the "Errors" list below it is unchanged
Scenario: Base URL and error sample
Then "Base URL" is a label above a CodeSnippet whose only tab is "Base URL" and whose code is {origin}
And the Errors sample is a CodeSnippet reading "HTTP/1.1 403 Forbidden", "Content-Type: text/plain", a blank line, "This action needs a real session."
Scenario: Copy buttons of the Integration page are independent
When the user copies the Base URL
Then only the Base URL button reads "Copied"
+114
View File
@@ -0,0 +1,114 @@
# QA: Integration images and variants, and CodeSnippet as the only code block
SPA only. No Go test, no live server, no SMTP, no real SQLite.
## Automated (Vitest + Vue Test Utils, `task test:web`)
Common setup: mount IntegrationPage inside DashboardShell at /integration with a test router, a resolved
verified session whose role is set per case, and `window.location.origin` stubbed to
`https://cms.renew.example`. Mock `/v1` through the same `useApi` seam as `variantsTestKit.ts`.
Stub `navigator.clipboard.writeText` per case and use fake timers. A spec fails on any `/v1` call
that is not listed for the case.
Variants fixture: the twelve board variants of the feature plus "print" (enabled false), in the board order, with
full item fields (fit, quality, generate_on_upload, generated_files, cache_size).
CodeSnippet component spec (no router, no `/v1`):
1. Three tabs: header with tablist aria-label "Language", the first tab selected with an underline class,
the Copy button named "Copy code".
2. A click on a tab switches the pre content. The Copy button writes the active tab's code.
3. One tab renders a muted label, not a role tab.
4. Copy: clipboard called with the code, "Copied" and icon check inside an aria-live "polite" element,
back to "Copy" after 2000 ms (advance 1999 and 2000). Two instances copy independently.
5. The pre has tabindex "0" and no syntax-highlight markup.
Integration images specs, one per design state, each with the mocked `/v1` call:
1. **Many variants** (owner): GET /v1/image-variants -> 200 with the fixture. Assert the section order (Media
group, Images and variants, Session only, Errors), anchor "images", the section index entry, "12 enabled variants",
12 table rows with "print" absent, the 264 px max-height class on the table body, the disabled note, exact curl,
HTML and JavaScript strings, HTML tab active, the Responses rows and bodies.
2. **One variant**: 200 with only "thumb" enabled. "1 enabled variant", one row, no max-height limit, the three
snippet slots all use "thumb", no disabled note.
3. **No variants, admin**: role admin, 200 with {"items": []}. Empty block, text, the "Go to image variants" link with
href /settings/media/variants, placeholder <variant-name> in the snippets, no table, no builder, Responses present.
Repeat with a fixture containing only disabled variants.
4. **No variants, not an admin**: role "member" (or no role). Assert no GET /v1/image-variants call on mount or on a
window focus event, the non-admin text, no link and no button. Add: role owner with a 403 answer renders the same state.
5. **Variant removed**: load the fixture, select "hero", make the mock return a list without "hero", then dispatch
`window.dispatchEvent(new Event('focus'))`. Assert a second GET, "hero (removed)", the warning alert title and text
with the exact 400 body, the URL snippet ends with ?v=hero, the preview label "No preview: unknown variant", 4 / 3
aspect and 50 % opacity. Repeat with the reload triggered by "Try again" after a failed first load.
6. **Loading**: GET /v1/image-variants pending. Assert three skeletons inside an aria-busy region, and no snippet,
no table, no builder, no Responses table.
7. **Error loading**: 500, then a rejected fetch. Assert the alert copy, the snippet present with
<variant-name>, no table and no builder, no Responses table. Click "Try again" and assert a second call
and the success state replaces the alert.
8. **Snippet copied**: click Copy on the "Request an image" snippet. Clipboard receives the active (HTML) code, button
reads "Copied" in an aria-live polite region, reverts after 2 s, and the builder's button is unaffected.
9. **URL builder copied**: the clipboard receives "https://cms.renew.example/media/north-wall.jpg?v=thumb".
Pick "square" first and assert the URL, preview label "square · 600×600 · JPEG" and the 600 / 600 aspect ratio.
Behaviour specs:
10. **Reload triggers**: mount sends one GET; each focus event sends one more; "Try again" sends one more; a reload with
the selected variant still enabled keeps the active tab and the selection. A non-admin sends none on any trigger.
11. **Responses table**: the two 400 bodies are the exact JSON strings of the feature; the 404 body is
"404 page not found"; the 413 body is "The source image is too large to generate this variant." and the 500 body is
"Unable to read the file"; no 409 or 422 row.
12. **Error sample label**: the Errors sample tab reads "Error response · text/plain" and no snippet in the images
section carries a text/plain label.
Migration specs (update or replace the existing assertions of `IntegrationView.spec.ts`, the EndpointCard spec
and the ApiKeys specs):
13. Base URL is a CodeSnippet with the single tab "Base URL", origin in the code, Copy -> "Copied" -> "Copy" after 2 s.
14. Authentication example: tabs curl and fetch for GET /v1/auth/me, no "Shell" or "JavaScript" label.
15. Expanded EndpointCard (POST /v1/article/): tabs fetch, curl, Response in one CodeSnippet, fetch active,
Copy copies the active tab, only that button reads "Copied". The Errors list is unchanged.
16. Errors sample is a CodeSnippet containing the four sample lines.
17. ApiKeyReveal: the key is in a CodeSnippet with the single tab "API key", the Copy button is focused when the
dialog opens, and Copy writes the full key and reads "Copied" for 2 s.
18. Source guard: nothing imports `@/components/CodeBlock.vue` (EndpointCard.vue and IntegrationView.vue included),
the file is deleted, and `CodeText.vue` still exists.
19. The Integration page makes the calls listed in the feature for owner and admin, and none for other roles. The old
"page stays static" assertions in `IntegrationView.spec.ts` are changed accordingly.
20. Every new string has a French entry (the existing i18n completeness spec must pass).
## Visual (`task visual:check -- integration`, desktop and mobile, light and dark)
Every capture must be within 10 %. Group "Integration: images" (board `IntegrationImages.dc.html` through AppFrame),
setups in `web/visual/states.ts`, with the board's sample data: origin `https://cms.renew.example`, image
`north-wall.jpg`, the twelve variants of the feature (plus disabled "print" so the note shows in Many), role admin
unless stated.
| State | Mock |
|---|---|
| Many variants | GET /v1/image-variants -> 12 enabled + "print" disabled, HTML tab active, builder on "thumb" |
| One variant | -> only "thumb" |
| No variants, admin | -> {"items": []}, role admin |
| No variants, not an admin | role without owner/admin, no call |
| Variant removed | builder on "hero", list without "hero" after the reload (first 5 as the board) |
| Loading | request never resolves |
| Error loading | status 500 |
| Snippet copied | Many with the snippet in the Copied state (clipboard stubbed) |
| URL builder copied | Many with the URL snippet in the Copied state |
Approved visual exceptions (user, own turn), each with `approvedBy` "Florian Sylvain":
- I2 One variant, desktop light, `maxPercent` just above the measured value (about 16 %), reason "board draws the striped preview offset by 32 px".
- I8 Snippet copied, desktop light, `maxPercent` just above the measured value (about 12 %), same reason.
- Errors, mobile, `maxPercent` just above the measured value (about 18.5 %), reason "the board changed; already 17.33 % at HEAD". Listed as a design-pass item.
- The `integration-api-keys` mobile failures (14 to 24 %) are NOT excepted. They predate this slice (floating inset dialog versus the board's flush bottom sheet, an overlay-pattern slice). They stay red and documented, and this slice's gate is scoped to the Integration screen.
Also re-run the existing groups affected by the migration and keep them within 10 %: Integration ("1. Default",
"2. Group scrolled, one card expanded", "3a. Copied: base URL", "3b. Copied: CodeSnippet", "4. Authentication",
"6. Errors") and API keys ("One-time reveal", "One-time reveal: copied"). The board names stay as the design gives
them. Existing pre-approved exceptions stay as recorded. Skipped: none are clock-sensitive.
## Manual checks (not coverable by screenshots)
- Keyboard: tab order inside a CodeSnippet is tabs, then Copy, then the `pre` (tabindex 0). Arrow keys move
between tabs. Enter and Space activate Copy. The focus ring is 2 px primary.
- A screen reader announces "Copied" politely after Copy, and not again on "Copy" returning.
- Hover and motion: Copy and tabs hover states, no motion on copy other than the label swap.
- Find-in-page and the index entry "Images and variants" scroll to `#images`; opening `/integration#images` lands on the section.
- The Variants table scrolls inside its 264 px body with the mouse wheel and keyboard, and the header stays visible.
- Real clipboard in a browser (secure context) copies the exact snippet text.
- Against a running server: `?v=<disabled>`, `?v=<unknown>` and `?width=400` answer 400 application/json with the
documented bodies, and a missing image answers 404 text/plain "404 page not found".