diff --git a/docs/contributing/artefact/files/plugins/lookup/applications.md b/docs/contributing/artefact/files/plugins/lookup/applications.md index af9ec8f005..67061c3713 100644 --- a/docs/contributing/artefact/files/plugins/lookup/applications.md +++ b/docs/contributing/artefact/files/plugins/lookup/applications.md @@ -18,7 +18,7 @@ The `applications` lookup is the ONLY supported runtime entry point for merged a | `lookup('applications')` | Full merged mapping keyed by `application_id`. | | `lookup('applications', '')` | Merged entry for that application. Raises when missing. | | `lookup('applications', '', default_value)` | Merged entry, or `default_value` when the entry is missing. | -| `lookup('applications', carrier=True)` | `{"key": [...], "applications": {...}}`: the full mapping plus the cache key it is valid under. Reserved for the constructor's carrier task (see below). | +| `lookup('applications', carrier=True)` | `{"key": [...], "applications": {...}}`: the full mapping plus the cache key it is valid under. Reserved for the `applications_carrier` action (see below). | ## Data Sources ๐Ÿ“š @@ -33,9 +33,11 @@ No other source is consulted. Ansible forks a fresh worker process for every task. The in-process cache inside `get_merged_applications` therefore never survives from one task to the next, and every task that touches the lookup would render the full tree again (about 16 to 23 seconds per task on a CI runner, measured on the workspace jobs). -The constructor stage renders once through the [applications_carrier](../../../../../../plugins/action/applications_carrier.py) action and parks the result together with its cache key in the host fact `_INFINITO_APPLICATIONS_RENDERED` (the action calls `lookup('applications', carrier=True)`). Every later worker inherits that fact at fork time; `get_merged_applications` serves it whenever the key still matches and renders only when it does not (a changed `applications`, `users`, `DOMAIN_PRIMARY` or `SYSTEM_EMAIL_DOMAIN`). +The constructor stage renders once through the [applications_carrier](../../../../../../plugins/action/applications_carrier.py) action and parks the result together with its cache key in the host fact `_INFINITO_APPLICATIONS_RENDERED` (the action calls `lookup('applications', carrier=True)`). Every later worker inherits that fact at fork time; `get_merged_applications` serves it whenever the key still matches and renders only when it does not (a changed `applications`, `users`, `DOMAIN_PRIMARY`, `SYSTEM_EMAIL_DOMAIN` or `group_names`, or a changed path or content of the tokens file). -- The task sits in `tasks/stages/01_constructor.yml` after the `networks` and `service_provider` merges, after the `add_host` placements and after the token store, so the payload renders with the same `group_names`, `groups` and user records a role would see. Moving it earlier bakes stale values into the play. +- The stage-wide task sits in `tasks/stages/01_constructor.yml` after the `networks` and `service_provider` merges, after the `add_host` placements and after the token store, so the payload renders with the same `group_names`, `groups` and user records a role would see. Moving it earlier bakes stale values into the play. +- The carrier is parked again after every role in `tasks/utils/setup/role.yml` and after every persisted token in `roles/sys-token-store/tasks/write.yml`. A re-park whose key still matches serves the carried payload without rendering. +- The tokens file enters the key as the SHA-256 of its content, memoised per inode, mtime and size, so a touch keeps the key. The carrier action opens the file before it computes the key, so an NFS client keys on revalidated attributes. - The action masks its own result: the fact is the whole rendered tree with every application's credentials, so it never reaches a log regardless of `MASK_CREDENTIALS_IN_LOGS`. - User records inside the payload are the token store's state at render time. Readers of tokens captured later in the play MUST go through `lookup('users', ...)` or `lookup('config', , 'users....')`, both of which read the users cache fresh. - The carrier is private to the lookup. Consumers MUST NOT read `_INFINITO_APPLICATIONS_RENDERED`; they keep going through `lookup('applications')`, which is what makes the carrier transparent. diff --git a/docs/requirements/037-corporate-design-overhaul.md b/docs/requirements/037-corporate-design-overhaul.md index 252c0db3a2..5170e76e7a 100644 --- a/docs/requirements/037-corporate-design-overhaul.md +++ b/docs/requirements/037-corporate-design-overhaul.md @@ -64,7 +64,7 @@ Two base commits changed many roles after their last run: `6c2de017f8` split ven - [x] web-app-discourse - [x] web-app-erpnext - [x] web-app-espocrm -- [ ] web-app-fediwall: literal colors in `templates/index.html.j2:18-27`; title not set through the wall config's native title. +- [x] web-app-fediwall - [ ] web-app-fider: the logo does not reach Fider while the object store is enabled. - [ ] web-app-flowise: `env.j2`, `playwright.env.j2` and `tasks/utils/mcp.yml` changed after the last green run. - [x] web-app-friendica @@ -83,13 +83,13 @@ Two base commits changed many roles after their last run: `6c2de017f8` split ven - [x] web-app-kix - [ ] web-app-lam: title neither set nor asserted; spec and `playwright.env.j2` rewritten after the last run. - [x] web-app-listmonk -- [ ] web-app-litellm: logo swapped by CSS `content` instead of LiteLLM's `logo_url` setting. +- [x] web-app-litellm - [x] web-app-littlejs -- [ ] web-app-magento: literal `darkgrey` at `style.css.j2:5513` and `:5554`; `style.css.j2` changed by `6c2de017f8` after the last run. +- [x] web-app-magento - [x] web-app-mastodon -- [ ] web-app-matomo: Matomo's own `themeMode` preference is not exercised (the spec flips the DOM); the administration is not asserted. +- [x] web-app-matomo - [x] web-app-matrix -- [ ] web-app-mattermost: about 50 literal RGB fallbacks inside `var()` in `style.css.j2`; `style.css.j2` changed by `6c2de017f8` after the last run. +- [x] web-app-mattermost - [x] web-app-mediawiki - [x] web-app-mini-qr - [x] web-app-mobilizon @@ -111,11 +111,11 @@ Two base commits changed many roles after their last run: `6c2de017f8` split ven - [x] web-app-pixelfed - [ ] web-app-postmarks: the server HTML and the Atom feeds keep the title `Postmarks`, because Postmarks reads `site_name` only from `account.json`, which switches on fediverse mode; `design.js` sets the browser title (upstream option in `roles/web-app-postmarks/TODO.md`). - [x] web-app-pretix -- [ ] web-app-prometheus: merges of `main` changed spec, services, volumes and tasks after the last run; literal colors in the logout button of `javascript.js.j2`. +- [x] web-app-prometheus - [x] web-app-roulette-wheel - [x] web-app-seaweedfs - [x] web-app-semaphore -- [ ] web-app-shopware: literal colors (`gold`, SVG fills) in `style.css.j2`; `style.css.j2` changed by `6c2de017f8` after the last run. +- [x] web-app-shopware - [x] web-app-snipe-it - [x] web-app-socialhome - [x] web-app-sphinx diff --git a/plugins/action/applications_carrier.py b/plugins/action/applications_carrier.py index ce05759532..93c479ff3d 100644 --- a/plugins/action/applications_carrier.py +++ b/plugins/action/applications_carrier.py @@ -14,13 +14,17 @@ instead of rendering:: for the merge block, which it drops again at the block's end, one after the ``add_host`` tasks for the swarm bootstrap roles, parked only when ``DEPLOYMENT_MODE`` is ``swarm``, and the stage-wide one after the token store. ``merged_applications_cache_key`` reads ``group_names`` and the -tokens file's path, mtime and size, so a carrier whose groups or tokens no +tokens file's path and content digest, so a carrier whose groups or tokens no longer match is rejected and the reader renders afresh:: - name: ๐ŸงŠ Drop the merge-block carrier so later stages re-render applications_carrier: clear: true +``tasks/utils/setup/role.yml`` parks one after every role and +``roles/sys-token-store/tasks/write.yml`` one after every persisted token; +where the key still matches they serve the carried payload without rendering. + The result is masked by the action itself: the fact is the whole rendered tree, about 0.4 MB with every application's credentials, and printing it at ``-v`` is neither credential debugging nor readable. @@ -39,7 +43,7 @@ from ansible.module_utils.parsing.convert_bool import boolean from ansible.plugins.action import ActionBase from ansible.plugins.loader import lookup_loader -from utils.cache.carrier import APPLICATIONS_RENDERED_FACT +from utils.cache.carrier import APPLICATIONS_RENDERED_FACT, revalidate_tokens_file class ActionModule(ActionBase): @@ -64,6 +68,7 @@ class ActionModule(ActionBase): ) return result + revalidate_tokens_file(task_vars) lookup = lookup_loader.get( "applications", loader=self._loader, templar=self._templar ) diff --git a/roles/svc-ai-litellm/templates/config.yaml.j2 b/roles/svc-ai-litellm/templates/config.yaml.j2 index 2a985c26e8..e168b383dc 100644 --- a/roles/svc-ai-litellm/templates/config.yaml.j2 +++ b/roles/svc-ai-litellm/templates/config.yaml.j2 @@ -39,6 +39,10 @@ litellm_settings: {% if LITELLM_ROUTER_ALIAS %} callbacks: ["router_hook.instance"] {% endif %} +{% if LITELLM_UI_LOGO_URL | length > 0 %} + ui_theme_config: + logo_url: {{ LITELLM_UI_LOGO_URL | to_json }} +{% endif %} general_settings: master_key: os.environ/LITELLM_MASTER_KEY diff --git a/roles/svc-ai-litellm/vars/main.yml b/roles/svc-ai-litellm/vars/main.yml index b99b67bd09..2ae3beafb5 100644 --- a/roles/svc-ai-litellm/vars/main.yml +++ b/roles/svc-ai-litellm/vars/main.yml @@ -56,3 +56,5 @@ LITELLM_UI_ROLE: "web-app-litellm" LITELLM_UI_SSO: "{{ (LITELLM_UI_ROLE in group_names) and (lookup('sso', LITELLM_UI_ROLE, 'is_oidc_native') | bool) }}" LITELLM_UI_BASE_URL: "{{ lookup('tls', LITELLM_UI_ROLE, 'url.base') }}" LITELLM_UI_ADMIN: "{{ lookup('users', 'platform_administrator').username }}" +LITELLM_UI_LOGO_URL: "{{ lookup('design', LITELLM_UI_ROLE).urls.logo.svg if ((LITELLM_UI_ROLE in group_names) and (lookup('config', LITELLM_UI_ROLE, 'services.design.enabled') + | bool) and (lookup('design', LITELLM_UI_ROLE).logo is string)) else '' }}" diff --git a/roles/sys-token-store/tasks/00_core.yml b/roles/sys-token-store/tasks/00_core.yml index 7f77bcb4c0..1d27b4468f 100644 --- a/roles/sys-token-store/tasks/00_core.yml +++ b/roles/sys-token-store/tasks/00_core.yml @@ -14,6 +14,8 @@ ansible.builtin.file: path: "{{ FILE_TOKENS }}" state: touch + modification_time: preserve + access_time: preserve owner: "{{ SYS_TOKEN_STORE_OWNER }}" group: "{{ SYS_TOKEN_STORE_GROUP }}" mode: "{{ SYS_TOKEN_STORE_FILE_MODE }}" diff --git a/roles/sys-token-store/tasks/write.yml b/roles/sys-token-store/tasks/write.yml index aae6fcdf4e..719cff4907 100644 --- a/roles/sys-token-store/tasks/write.yml +++ b/roles/sys-token-store/tasks/write.yml @@ -53,6 +53,7 @@ run_once: true no_log: "{{ MASK_CREDENTIALS_IN_LOGS | bool }}" become: true + register: sys_token_store_persisted - name: "๐Ÿชž Mirror the store onto the controller, where every users lookup reads" ansible.builtin.copy: @@ -63,3 +64,8 @@ delegate_to: localhost run_once: true no_log: "{{ MASK_CREDENTIALS_IN_LOGS | bool }}" + register: sys_token_store_mirrored + + - name: "๐ŸงŠ Re-render the applications SPOT the new token invalidated" + applications_carrier: {} + when: sys_token_store_persisted is changed or sys_token_store_mirrored is changed diff --git a/roles/web-app-bluesky/Administration.md b/roles/web-app-bluesky/Administration.md index a610f909dd..82fdc48a2f 100644 --- a/roles/web-app-bluesky/Administration.md +++ b/roles/web-app-bluesky/Administration.md @@ -3,15 +3,16 @@ ## Create a user via POST ```bash -curl -X POST https://your-pds-domain/xrpc/com.atproto.server.createAccount \ - --user "admin:${PDS_ADMIN_PASSWORD}" \ - -H "Content-Type: application/json" \ - -d '{ - "email": "user@example.com", - "handle": "username", - "password": "securepassword123", - "inviteCode": "optional-invite-code" - }' +compose exec -T pds printenv PDS_ADMIN_PASSWORD \ + | sed 's/[\\"]/\\&/g; s/.*/user = "admin:&"/' \ + | curl --config - -X POST https://your-pds-domain/xrpc/com.atproto.server.createAccount \ + -H "Content-Type: application/json" \ + -d '{ + "email": "user@example.com", + "handle": "username", + "password": "securepassword123", + "inviteCode": "optional-invite-code" + }' ``` ## Use pdsadmin diff --git a/roles/web-app-bluesky/files/playwright/_appview.js b/roles/web-app-bluesky/files/playwright/_appview.js index d5e89d07e9..590297b66c 100644 --- a/roles/web-app-bluesky/files/playwright/_appview.js +++ b/roles/web-app-bluesky/files/playwright/_appview.js @@ -56,10 +56,13 @@ async function restorePreferences(request, pdsBaseUrl, accessJwt, preferences) { }).then((response) => { if (response.status() !== 200) throw new Error(`putPreferences answered ${response.status()}`); }); + let timer; const deadline = new Promise((_, reject) => { - setTimeout(() => reject(new Error("putPreferences timed out")), resolveTimeout(45_000)); + timer = setTimeout(() => reject(new Error("putPreferences timed out")), resolveTimeout(45_000)); }); - await Promise.race([restore, deadline]).catch((err) => console.warn(`bluesky: preferences not restored: ${err.message}`)); + await Promise.race([restore, deadline]) + .catch((err) => console.warn(`bluesky: preferences not restored: ${err.message}`)) + .finally(() => clearTimeout(timer)); } function collectAppViewResponses(page) { diff --git a/roles/web-app-fediwall/TODO.md b/roles/web-app-fediwall/TODO.md index e3a003fd9c..5ccc706b21 100644 --- a/roles/web-app-fediwall/TODO.md +++ b/roles/web-app-fediwall/TODO.md @@ -2,4 +2,3 @@ - Implement multi-wall runtime upload behind OIDC (archived req 016). - The wall list page (more than one wall) links `./favicon.png` at the site root in `templates/index.html.j2`, and `https:///favicon.png` answers 404 because `tasks/01_manager_ops.yml` stages the icons per wall slug only. Options: stage the three icons at the root too, or link the icon of the first wall. -- The wall list page without the `design` service shows a hovered wall link in `#1a73e8` on `#f0f4ff`, contrast 4.10:1 where text needs 4.5:1. Cause: the color literals in `templates/index.html.j2`. Options: darken the link literal, or lighten the hover background. diff --git a/roles/web-app-fediwall/files/playwright/test-design.js b/roles/web-app-fediwall/files/playwright/test-design.js index 5120caff94..210d4b3072 100644 --- a/roles/web-app-fediwall/files/playwright/test-design.js +++ b/roles/web-app-fediwall/files/playwright/test-design.js @@ -9,16 +9,12 @@ const { galleryEnabled, tokenValue, } = require("./design"); -const { decodeDotenvQuotedValue, gotoOnion } = require("./personas"); +const { apiGetOnion, decodeDotenvQuotedValue, gotoOnion } = require("./personas"); const { skipUnlessServiceEnabled } = require("./service-gating"); const { resolveTimeout } = require("./timeouts"); -const title = decodeDotenvQuotedValue(process.env.DESIGN_TITLE || ""); -const faviconIcoUrl = decodeDotenvQuotedValue(process.env.DESIGN_FAVICON_URL || ""); -const faviconPngUrl = decodeDotenvQuotedValue(process.env.DESIGN_FAVICON_PNG_URL || ""); -const faviconSvgUrl = decodeDotenvQuotedValue(process.env.DESIGN_FAVICON_SVG_URL || ""); - const MODES = ["light", "dark"]; +const INJECTED_SNIPPETS = /[\s\S]*?/g; const SOURCE = "social.example"; const QUIET_SOURCE = "quiet.example"; const BROKEN_SOURCE = "down.example"; @@ -347,45 +343,58 @@ exports.register = function (shared) { await page.emulateMedia({ colorScheme: null }); }); - test("design: the wall shows the generated icons and the configured title", async ({ page }) => { + test("design: the wall takes the configured title through its own configuration", async ({ page, request }) => { skipUnlessServiceEnabled("design"); - test.skip(!faviconIcoUrl && !title, "logo and title replacement are disabled for this role"); + const title = decodeDotenvQuotedValue(process.env.DESIGN_TITLE || ""); + test.skip(!title, "title replacement is disabled for this role"); + const response = await apiGetOnion(request, `${wall}wall-config.json`); + expect(response.ok(), "the wall configuration must be served").toBe(true); + const configured = (await response.json()).title; + expect(configured.split(" - "), "the wall configuration carries the design title").toContain(title); await openWall(page); - if (title) { - await expect - .poll(async () => (await page.title()).split(title).length - 1, { - message: "the title of the wall must carry the configured title exactly once", - }) - .toBe(1); - } - if (faviconIcoUrl) { - expect(faviconPngUrl, "DESIGN_FAVICON_PNG_URL must accompany the ico favicon").toBeTruthy(); - expect(faviconSvgUrl, "DESIGN_FAVICON_SVG_URL must accompany the ico favicon").toBeTruthy(); - const icons = [ - ["image/png", faviconPngUrl], - ["image/svg+xml", faviconSvgUrl], - ["image/vnd.microsoft.icon", faviconIcoUrl], - ]; - for (const [type, url] of icons) { - await expect(page.locator(`link[rel~='icon'][type='${type}']`)).toHaveAttribute("href", url); - expect( - await page.evaluate( - (src) => - new Promise((resolve) => { - const image = new Image(); - image.onload = () => resolve(image.naturalWidth > 0); - image.onerror = () => resolve(false); - image.src = src; - }), - url, - ), - `the page must be allowed to load ${url}`, - ).toBe(true); - } + await expect(page).toHaveTitle(configured); + await expect + .poll(async () => (await page.title()).split(title).length - 1, { + message: "the title of the wall must carry the configured title exactly once", + }) + .toBe(1); + await openDialog(page, "appearance"); + await expect(page.locator(TITLE_FIELD)).toHaveValue(configured); + }); + + test("design: the wall shows the generated icons", async ({ page }) => { + skipUnlessServiceEnabled("design"); + const faviconIcoUrl = decodeDotenvQuotedValue(process.env.DESIGN_FAVICON_URL || ""); + test.skip(!faviconIcoUrl, "logo replacement is disabled for this role"); + const faviconPngUrl = decodeDotenvQuotedValue(process.env.DESIGN_FAVICON_PNG_URL || ""); + const faviconSvgUrl = decodeDotenvQuotedValue(process.env.DESIGN_FAVICON_SVG_URL || ""); + expect(faviconPngUrl, "DESIGN_FAVICON_PNG_URL must accompany the ico favicon").toBeTruthy(); + expect(faviconSvgUrl, "DESIGN_FAVICON_SVG_URL must accompany the ico favicon").toBeTruthy(); + const icons = [ + ["image/png", faviconPngUrl], + ["image/svg+xml", faviconSvgUrl], + ["image/vnd.microsoft.icon", faviconIcoUrl], + ]; + await openWall(page); + for (const [type, url] of icons) { + await expect(page.locator(`link[rel~='icon'][type='${type}']`)).toHaveAttribute("href", url); + expect( + await page.evaluate( + (src) => + new Promise((resolve) => { + const image = new Image(); + image.onload = () => resolve(image.naturalWidth > 0); + image.onerror = () => resolve(false); + image.src = src; + }), + url, + ), + `the page must be allowed to load ${url}`, + ).toBe(true); } }); - test("design: the wall list takes its colors from the palette", async ({ page }) => { + test("design: the wall list takes its colors from the palette", async ({ page, request }) => { skipUnlessServiceEnabled("design"); test.skip(shared.env.wallSlugs.length < 2, "a single wall is redirected to instead of listed"); await gotoOnion(page, `${shared.env.appBaseUrl}/`); @@ -400,6 +409,22 @@ exports.register = function (shared) { } await page.emulateMedia({ colorScheme: null }); await assertReadable(page, ["h1", WALL_LINK], "fediwall wall list"); + const raw = (await (await apiGetOnion(request, `${shared.env.appBaseUrl}/`)).text()).replace(INJECTED_SNIPPETS, ""); + const styles = [...raw.matchAll(/]*>([\s\S]*?)<\/style>/gi)].map((match) => match[1]).join("\n"); + const literals = await page.evaluate((css) => { + const keyword = (word) => + /^[a-z]+$/i.test(word) && + !/^(?:transparent|currentcolor|inherit|initial|unset|revert|none)$/i.test(word) && + CSS.supports("color", word); + const found = []; + for (const [, name, value] of css.matchAll(/(?:^|[{;])\s*([a-z-]+)\s*:\s*([^;{}]+)/gm)) { + if (/#[0-9a-f]{3,8}\b|\b(?:rgba?|hsla?)\(/i.test(value) || value.split(/[\s,()]+/).some(keyword)) { + found.push(`${name}: ${value.trim()}`); + } + } + return found; + }, styles); + expect(literals, "the wall list page itself sets no color; the palette arrives through the injected layer").toEqual([]); }); function galleryViews() { diff --git a/roles/web-app-fediwall/templates/design.js.j2 b/roles/web-app-fediwall/templates/design.js.j2 index 94556fb493..3eb04b4ca2 100644 --- a/roles/web-app-fediwall/templates/design.js.j2 +++ b/roles/web-app-fediwall/templates/design.js.j2 @@ -14,20 +14,6 @@ new MutationObserver(mirror).observe(root, { attributes: true, attributeFilter: ["data-bs-theme"] }); prefersDark.addEventListener("change", mirror); mirror(); -{% if design.title is string %} - var title = {{ design.title | to_json }}; - function brandTitle() { - var current = document.title; - if (current.indexOf(title) === -1) { - document.title = current ? current + " - " + title : title; - } - } - var titleElement = document.querySelector("title"); - if (titleElement) { - new MutationObserver(brandTitle).observe(titleElement, { childList: true, characterData: true, subtree: true }); - brandTitle(); - } -{% endif %} {% if design.logo is string %} var icons = { "image/png": {{ design.urls.favicon.png | to_json }}, diff --git a/roles/web-app-fediwall/templates/index.html.j2 b/roles/web-app-fediwall/templates/index.html.j2 index bafd3c6673..4b74a76bd9 100644 --- a/roles/web-app-fediwall/templates/index.html.j2 +++ b/roles/web-app-fediwall/templates/index.html.j2 @@ -14,18 +14,13 @@ {% else %} Fediwalls on {{ DOMAIN_PRIMARY }} + diff --git a/roles/web-app-fediwall/templates/wall-config.json.j2 b/roles/web-app-fediwall/templates/wall-config.json.j2 index 04fd09b83f..c5ed77d6dc 100644 --- a/roles/web-app-fediwall/templates/wall-config.json.j2 +++ b/roles/web-app-fediwall/templates/wall-config.json.j2 @@ -23,7 +23,7 @@ "limit": {{ wall.config.limit | to_json }}, "interval": {{ wall.config.interval | to_json }}, - "title": {{ wall.config.title | to_json }}, + "title": {{ ([wall.config.title, FEDIWALL_DESIGN_TITLE] | select | unique | join(' - ')) | to_json }}, "theme": {{ wall.config.theme | to_json }}, "showInfobar": {{ wall.config.showInfobar | to_json }}, "showText": {{ wall.config.showText | to_json }}, diff --git a/roles/web-app-fediwall/vars/main.yml b/roles/web-app-fediwall/vars/main.yml index 3315952cb0..7f06385c12 100644 --- a/roles/web-app-fediwall/vars/main.yml +++ b/roles/web-app-fediwall/vars/main.yml @@ -15,6 +15,8 @@ FEDIWALL_UPSTREAM_VERSION: "{{ lookup('config', application_id, 'services.' ~ en FEDIWALL_UPSTREAM_TARBALL_URL: "{{ lookup('config', application_id, 'services.' ~ entity_name ~ '.upstream.tarball_url') }}" FEDIWALL_WALLS: "{{ lookup('config', application_id, 'services.' ~ entity_name ~ '.walls') }}" +FEDIWALL_DESIGN_ENABLED: "{{ lookup('config', application_id, 'services.design.enabled') | bool }}" +FEDIWALL_DESIGN_TITLE: "{{ lookup('design', application_id).title if FEDIWALL_DESIGN_ENABLED | bool and lookup('design', application_id).title is string else '' }}" FEDIWALL_BUILD_DIR_REL: "build" FEDIWALL_BUILD_DIR_ABS: "{{ [ lookup('container', application_id, 'directories.instance'), FEDIWALL_BUILD_DIR_REL ] | path_join }}" diff --git a/roles/web-app-litellm/files/playwright/test-design.js b/roles/web-app-litellm/files/playwright/test-design.js index dc089e3128..2c03f8a4e2 100644 --- a/roles/web-app-litellm/files/playwright/test-design.js +++ b/roles/web-app-litellm/files/playwright/test-design.js @@ -375,13 +375,28 @@ exports.register = function (shared) { await shared.signIn(page); if (title) await expect(page).toHaveTitle(title); if (logoUrl) { - await expect(page.locator(shared.SHELL)).toHaveCSS("content", `url("${logoUrl}")`); + const settings = await (await apiGetOnion(page.request, `${base}/get/ui_theme_settings`)).json(); + expect(settings.values.logo_url, "LiteLLM's own logo_url setting must carry the corporate lockup").toBe(logoUrl); + const brand = page.locator(shared.SHELL); + await expect(brand, "the navbar must take its logo from the setting").toHaveAttribute("src", logoUrl); + await expect(brand, "no stylesheet may swap the navbar logo").toHaveCSS("content", "normal"); + await expect + .poll(() => brand.evaluate((image) => image.complete && image.naturalWidth > 0), { message: "the lockup must load" }) + .toBe(true); + const box = await brand.boundingBox(); + expect(box.width, "the wide lockup must carry the title next to the symbol").toBeGreaterThan(2 * box.height); for (const icon of await page.locator("link[rel~='icon']").all()) { await expect(icon).toHaveAttribute("href", faviconUrl); } for (const url of [logoUrl, faviconUrl]) { expect((await apiGetOnion(page.request, url)).ok(), `${url} is published on the CDN`).toBe(true); } + await gotoOnion(page, `${base}/ui/?page=ui-theme`); + await ready(page); + await expect( + page.locator("input[placeholder='https://example.com/logo.png']"), + "the UI theme page must show the configured logo URL", + ).toHaveValue(logoUrl); } }); diff --git a/roles/web-app-litellm/templates/style.css.j2 b/roles/web-app-litellm/templates/style.css.j2 index f5065564e7..70fccbd7e6 100644 --- a/roles/web-app-litellm/templates/style.css.j2 +++ b/roles/web-app-litellm/templates/style.css.j2 @@ -544,9 +544,6 @@ button[id^="headlessui-listbox-button"] { color: var(--design-text); } {% if design.logo is string %} -img[alt="LiteLLM Brand"] { - content: url("{{ design.urls.logo.svg }}"); -} {{ sign_in }} .logo { height: 3rem; font-size: 0; diff --git a/roles/web-app-magento/TODO.md b/roles/web-app-magento/TODO.md index f5676b602a..7868713708 100644 --- a/roles/web-app-magento/TODO.md +++ b/roles/web-app-magento/TODO.md @@ -5,6 +5,5 @@ - `meta/csp.yml` grants `unsafe-eval` for `script-src`: Knockout compiles every `data-bind` attribute with `new Function`, and without the grant the Knockout forms of the admin stay empty ("Unable to parse bindings", measured on the code form at `tfa/google/auth`). The grant covers the storefront too, which runs the same Knockout components. Open for the role's owner: keep it, or serve the admin under a policy of its own. - The admin dashboard requests a launch script from the host `assets.adobedtm.com` (the admin usage tracking of `Magento_AdminAnalytics`, whose notification dialog opens on the first dashboard visit) and the content security policy blocks it in `script-src-elem`, one console error per dashboard visit. Options: switch the tracking off with `admin/usage/enabled`, or allow the host. - The persona test "administrator: app โ†’ universal logout" in `files/playwright/playwright.spec.js` does not enter the admin while `two_factor.enabled` is true: the shared native sign-in stops at Magento's two-factor page and the flow logs out through the link of that page. With the flag off it enters the admin and signs out through the account menu. `files/playwright/test-admin-sign-in.js` asserts the sign-in in both states of the flag. -- Indexers: after the install every indexer runs by schedule (`bin/magento indexer:status` shows `Update On: Schedule` for all of them, with a backlog) and no container runs Magento's cron, so a product or category created after the install never reaches the storefront. The three products the design gallery seeds over REST stayed out of the category listing, and `POST /rest/default/V1/carts/mine/items` answered 400 "Product that you are trying to add is not available." until `bin/magento indexer:reindex` was run by hand. Options: a service that runs `bin/magento cron:run` every minute, or `bin/magento indexer:set-mode realtime` in `tasks/01_setup.yml`. The gallery test in `files/playwright/test-design.js` needs one of them; the other design tests seed nothing. - Search engine: `bin/magento indexer:reindex catalogsearch_fulltext` failed with `index_create_block_exception`, "cluster create-index blocked (api)". OpenSearch had set `cluster.blocks.create_index` itself because the disk of the host was 92 % full (140 GB free of 1.7 TB), which is above its percentage watermark; category pages and the search then list nothing. On the test stack the block was lifted by hand with the transient cluster setting `cluster.routing.allocation.disk.threshold_enabled: false`. Options: that setting or absolute watermarks in the environment of the `search` service. - Storefront links carry `/index.php/` (the logo links to `/index.php/`): `web/seo/use_rewrites` is not set. Options: `--use-rewrites=1` at `setup:install`, or `bin/magento config:set web/seo/use_rewrites 1`. diff --git a/roles/web-app-magento/files/playwright/test-design.js b/roles/web-app-magento/files/playwright/test-design.js index c5c9ee0b8e..64eeb4adec 100644 --- a/roles/web-app-magento/files/playwright/test-design.js +++ b/roles/web-app-magento/files/playwright/test-design.js @@ -28,6 +28,9 @@ const SIGN_IN_BUTTON = `${SIGN_IN_FORM} button.action.login`; const HEADER_PANEL = ".page-header .panel.wrapper"; const INJECTED_SHEET = /\/_shared\/css\/|\/roles\/web-app-magento\//; const ROLE_SHEET = "link[href*='/roles/web-app-magento/'][href*='style.css']"; +const COLOR_PROPERTY = + /^(?:[a-z-]*-)?color$|^(?:background(?:-image)?|fill|stroke|box-shadow|text-shadow|border(?:-(?:top|right|bottom|left))?|outline|column-rule)$/; +const NO_COLOR = /^(?:transparent|currentcolor|inherit|initial|unset|revert|none)$/i; const CACHE_GRID = "#cache_grid_table"; const TREE_SELECTION = ".jstree-default .jstree-clicked"; @@ -197,6 +200,32 @@ test("design: every image the role stylesheet references is served", async ({ pa } }); +test("design: the role stylesheet sets every color through a token", async ({ page }) => { + skipUnlessServiceEnabled("design"); + await openShop(page, "/", ".page-footer"); + const sheet = await page.locator(ROLE_SHEET).first().evaluate((link) => link.href); + const css = await (await apiGetOnion(page.request, sheet)).text(); + const offenders = await page.evaluate( + ([source, property, keyword]) => { + const colorProperty = new RegExp(property); + const noColor = new RegExp(keyword, "i"); + const found = []; + for (const [, name, value] of source.matchAll(/(?:^|[{;])\s*([a-z-]+)\s*:\s*([^;{}]+)/gm)) { + if (!colorProperty.test(name) || value.includes("var(--design-") || value.includes("url(")) continue; + const literal = + /#[0-9a-f]{3,8}\b|\b(?:rgba?|hsla?|hwb|lab|lch|oklab|oklch|color)\(/i.test(value) || + value + .split(/[\s,()]+/) + .some((word) => /^[a-z]+$/i.test(word) && !noColor.test(word) && CSS.supports("color", word)); + if (literal) found.push(`${name}: ${value.trim()}`); + } + return found; + }, + [css, COLOR_PROPERTY.source, NO_COLOR.source], + ); + expect(offenders, "every color of the role stylesheet comes from a --design-* token").toEqual([]); +}); + test("design: focus stops inside the storefront header panel draw in the frame text color", async ({ page }) => { skipUnlessServiceEnabled("design"); await page.setViewportSize(DESKTOP); diff --git a/roles/web-app-magento/tasks/01_setup.yml b/roles/web-app-magento/tasks/01_setup.yml index 599cb2caeb..2931c16026 100644 --- a/roles/web-app-magento/tasks/01_setup.yml +++ b/roles/web-app-magento/tasks/01_setup.yml @@ -99,6 +99,21 @@ or magento_admin_password_realign.stdout is search('\[FAIL\]') no_log: "{{ MASK_CREDENTIALS_IN_LOGS | bool }}" +- name: "๐Ÿ—‚๏ธ Index on save, so catalog changes reach the storefront without a cron" + ansible.builtin.shell: >- + container exec --user {{ MAGENTO_USER }} {{ MAGENTO_PHP_CONTAINER_ADDRESS }} + bash -lc "cd {{ MAGENTO_ROOT }} && bin/magento indexer:set-mode realtime" + register: magento_indexer_mode + changed_when: "'was changed from' in magento_indexer_mode.stdout" + +- name: "๐Ÿ—‚๏ธ Rebuild the indexes the schedule left behind" + ansible.builtin.shell: >- + container exec --user {{ MAGENTO_USER }} {{ MAGENTO_PHP_CONTAINER_ADDRESS }} + bash -lc "cd {{ MAGENTO_ROOT }} && bin/magento indexer:reindex" + register: magento_indexer_reindex + changed_when: "'rebuilt successfully' in magento_indexer_reindex.stdout" + when: magento_indexer_mode is changed + - name: "๐Ÿชฃ Wire Magento to its S3 remote storage" when: MAGENTO_OBJSTORE_ENABLED | bool block: diff --git a/roles/web-app-magento/templates/style.css.j2 b/roles/web-app-magento/templates/style.css.j2 index 7983c4b572..a529c936e9 100644 --- a/roles/web-app-magento/templates/style.css.j2 +++ b/roles/web-app-magento/templates/style.css.j2 @@ -5510,7 +5510,7 @@ border-bottom-color: var(--design-text); } & .pagebuilder-stage-wrapper.stage-full-screen .pagebuilder-panel-wrapper .pagebuilder-panel::-webkit-scrollbar-thumb { - background-color: darkgrey; + background-color: var(--design-border-strong); } & .pagebuilder-stage-wrapper.stage-full-screen .pagebuilder-panel-wrapper .pagebuilder-panel .panel-controls { background-color: var(--design-text); @@ -5551,7 +5551,7 @@ border-bottom-color: var(--design-text); } & .pagebuilder-wysiwyg-snapshot .pagebuilder-panel-wrapper.transition-out .pagebuilder-panel::-webkit-scrollbar-thumb { - background-color: darkgrey; + background-color: var(--design-border-strong); } & .pagebuilder-wysiwyg-snapshot .pagebuilder-panel-wrapper.transition-out .pagebuilder-panel .panel-controls { background-color: var(--design-text); diff --git a/roles/web-app-matomo/files/design/theme.less b/roles/web-app-matomo/files/design/theme.less index 10f8c8b080..56a86af18c 100644 --- a/roles/web-app-matomo/files/design/theme.less +++ b/roles/web-app-matomo/files/design/theme.less @@ -201,3 +201,12 @@ div.dataTableVizAllColumns tr.subDataTable > td > span.label::before { #loginPage .hints { color: var(--design-text-muted); } + +[data-theme-mode]:root table.entityTable tbody tr td { + border-bottom-color: var(--design-border) !important; +} + +[data-theme-mode]:root .tableActionBar button:first-child:not(:disabled):not([disabled]), +[data-theme-mode]:root .tableActionBar a:first-child { + color: var(--design-on-primary); +} diff --git a/roles/web-app-matomo/files/playwright/test-design.js b/roles/web-app-matomo/files/playwright/test-design.js index e89818c25e..b0f9821e56 100644 --- a/roles/web-app-matomo/files/playwright/test-design.js +++ b/roles/web-app-matomo/files/playwright/test-design.js @@ -7,11 +7,12 @@ const { assertToken, captureDesignGallery, galleryEnabled, + tokenValue, } = require("./design"); const { apiFetchOnion, apiGetOnion, gotoOnion } = require("./personas"); const { skipUnlessServiceEnabled } = require("./service-gating"); const { resolveTimeout } = require("./timeouts"); -const { appBaseUrl, loginAsAdmin, matomoApiToken } = require("./_shared"); +const { adminUsername, appBaseUrl, loginAsAdmin, matomoApiToken } = require("./_shared"); const THEME = "InfinitoDesign"; const MODES = ["light", "dark"]; @@ -23,6 +24,14 @@ const BUSY = ".widgetLoading:visible, .loadingPiwik:visible, .sparklineLoadingSk const USER = "#login_form_login"; const SUBMIT = "#login_form_submit"; const SHOWCASE_VISITS = 20; +const SETTINGS_FORM = "#userSettingsTable"; +const SETTINGS_SAVE = `${SETTINGS_FORM} .matomo-save-button input.btn`; +const USERS_TABLE = "#manageUsersTable"; +const USER_CELL = `${USERS_TABLE} tbody td#userLogin`; +const ADD_USER = ".usersManager .btn.add-new-user"; +const ADMIN_MENU = "#secondNavBar.Menu--admin"; +const ADMIN_ENTRY = `${ADMIN_MENU} .navbar > li > ul > li.active > a.item`; +const ADD_REPORT = ".tableActionBar #add-report"; function base() { return appBaseUrl.replace(/\/$/, ""); @@ -138,10 +147,119 @@ test("design: the login page and the administrator follow the browser color sche await signIn(page); await gotoOnion(page, homeUrl()); await expect(page.locator("html"), "the administrator must follow the browser").toHaveAttribute("data-theme-mode", "auto"); - await page.evaluate(() => document.documentElement.setAttribute("data-theme-mode", "dark")); - await expect(page.locator("html"), "a dark Matomo theme mode must reach the design tokens").toHaveAttribute("data-design-theme", "dark"); - await page.evaluate(() => document.documentElement.setAttribute("data-theme-mode", "auto")); - await expect(page.locator("html")).not.toHaveAttribute("data-design-theme", /.+/); +}); + +/** + * Args: + * page: Playwright page on Matomo's personal settings; picks a theme mode through the form and saves it. + * mode: "light", "dark" or "auto". + */ +async function pickThemeMode(page, mode) { + await shown(page, SETTINGS_FORM); + await page + .locator(`${SETTINGS_FORM} label`) + .filter({ has: page.locator(`input[name="themeMode"][value="${mode}"]`) }) + .click(); + await page.locator(SETTINGS_SAVE).click(); + await expect(page.locator("html"), `saving the personal settings applies the ${mode} theme mode`).toHaveAttribute( + "data-theme-mode", + mode, + { timeout: resolveTimeout(15_000) }, + ); +} + +test("design: Matomo's own theme setting drives the palette", async ({ page, request }) => { + skipUnlessServiceEnabled("design"); + await page.setViewportSize(DESKTOP); + const html = page.locator("html"); + const surface = () => tokenValue(page, "--design-surface-1", "background-color"); + await signIn(page); + try { + for (const [browser, picked] of [ + ["light", "dark"], + ["dark", "light"], + ]) { + await page.emulateMedia({ colorScheme: browser }); + await gotoOnion(page, adminUrl("UsersManager", "userSettings")); + await shown(page, SETTINGS_FORM); + await expect(html, "the administrator starts on the provisioned auto mode").toHaveAttribute("data-theme-mode", "auto"); + const followed = await surface(); + await pickThemeMode(page, picked); + await expect(html).toHaveAttribute("data-design-theme", picked); + await expect + .poll(surface, { message: `a ${picked} Matomo theme in a ${browser} browser must switch the tokens` }) + .not.toBe(followed); + await assertToken(page, SETTINGS_SAVE, "background-color", "--design-primary", `save button, ${picked} theme`); + await assertToken(page, SETTINGS_SAVE, "color", "--design-on-primary", `save button, ${picked} theme`); + await gotoOnion(page, homeUrl()); + await expect(html, "the theme mode is stored with the account").toHaveAttribute("data-theme-mode", picked); + await expect(html).toHaveAttribute("data-design-theme", picked); + await gotoOnion(page, adminUrl("UsersManager", "userSettings")); + await pickThemeMode(page, "auto"); + await expect(html).not.toHaveAttribute("data-design-theme", /.+/); + } + } finally { + await page.emulateMedia({ colorScheme: null }); + await apiFetchOnion(request, `${base()}/index.php`, { + method: "POST", + form: { + module: "API", + method: "UsersManager.setUserPreference", + format: "json", + userLogin: adminUsername, + preferenceName: "themeMode", + preferenceValue: "auto", + token_auth: matomoApiToken, + }, + }); + } +}); + +test("design: the administration takes menu, tables, cards and primary actions from the palette", async ({ page }) => { + skipUnlessServiceEnabled("design"); + await page.setViewportSize(DESKTOP); + await signIn(page); + await gotoOnion(page, adminUrl("UsersManager", "index")); + await shown(page, USER_CELL); + await assertDesignTokens(page, "matomo administration"); + for (const mode of MODES) { + await page.emulateMedia({ colorScheme: mode }); + await assertToken(page, "body", "background-color", "--design-surface-1", `admin page ${mode}`); + await assertToken(page, ADMIN_MENU, "background-color", "--design-surface-2", `admin menu ${mode}`); + await assertToken(page, ADMIN_ENTRY, "color", "--design-link", `active admin entry ${mode}`); + await assertToken(page, ADMIN_ENTRY, "background-color", "--design-surface-hover", `active admin entry ${mode}`); + await assertToken(page, `${USERS_TABLE} thead th`, "background-color", "--design-surface-1", `user table head ${mode}`); + await assertToken(page, `${USERS_TABLE} thead th`, "color", "--design-text", `user table head ${mode}`); + await assertToken(page, USER_CELL, "background-color", "--design-surface-2", `user row ${mode}`); + await assertToken(page, USER_CELL, "color", "--design-text", `user row ${mode}`); + await assertToken(page, USER_CELL, "border-bottom-color", "--design-border", `user row divider ${mode}`); + await assertToken(page, ADD_USER, "background-color", "--design-primary", `add user ${mode}`); + await assertToken(page, ADD_USER, "color", "--design-on-primary", `add user ${mode}`); + } + await page.emulateMedia({ colorScheme: null }); + await assertLightAndDark(page, USER_CELL, "matomo users"); + await assertReadable( + page, + [ + `${USERS_TABLE} thead th.first`, + USER_CELL, + ADD_USER, + ADMIN_ENTRY, + { selector: `${ADMIN_MENU} .navbar > li.active > a.item`, optional: true }, + ], + "matomo users", + ); + + await gotoOnion(page, adminUrl("ScheduledReports", "index")); + await shown(page, ADD_REPORT); + for (const mode of MODES) { + await page.emulateMedia({ colorScheme: mode }); + await assertToken(page, "#entityEditContainer", "background-color", "--design-surface-2", `report card ${mode}`); + await assertToken(page, ADD_REPORT, "background-color", "--design-primary", `create report ${mode}`); + await assertToken(page, ADD_REPORT, "color", "--design-on-primary", `create report ${mode}`); + } + await page.emulateMedia({ colorScheme: null }); + await assertReadable(page, [ADD_REPORT, "#entityEditContainer .card-title"], "matomo email reports"); }); test("design: reports take surfaces, dividers and text from the palette", async ({ page, request }) => { diff --git a/roles/web-app-mattermost/files/playwright/test-design.js b/roles/web-app-mattermost/files/playwright/test-design.js index ca6aab5339..2403418d9f 100644 --- a/roles/web-app-mattermost/files/playwright/test-design.js +++ b/roles/web-app-mattermost/files/playwright/test-design.js @@ -49,6 +49,9 @@ const FIRST_RUN = [ ["onboarding_task_list", "onboarding_task_list_show", "false"], ["onboarding_task_list", "onboarding_task_list_open", "false"], ]; +const ROLE_SHEET = "link[href*='/roles/web-app-mattermost/'][href*='style.css']"; +const COLOR_PROPERTY = + /^(?:--[\w-]+|(?:[a-z-]*-)?color|background(?:-image)?|fill|stroke|box-shadow|text-shadow|border(?:-(?:top|right|bottom|left))?|outline|column-rule)$/; function shown(selector) { return async (page) => { @@ -307,6 +310,35 @@ exports.register = function (shared) { await assertDesignTokens(page, "Mattermost"); }); + test("design: the role stylesheet sets every color through a token", async ({ page }) => { + skipUnlessServiceEnabled("design"); + await openSignIn(page); + const sheet = await page.locator(ROLE_SHEET).first().evaluate((link) => link.href); + const css = await (await apiGetOnion(page.request, sheet)).text(); + const offenders = await page.evaluate( + ([source, property]) => { + const colorProperty = new RegExp(property); + const keyword = (word) => + /^[a-z]+$/i.test(word) && + !/^(?:transparent|currentcolor|inherit|initial|unset|revert|none)$/i.test(word) && + CSS.supports("color", word); + const found = []; + for (const [, name, value] of source.matchAll(/(?:^|[{;])\s*(--[\w-]+|[a-z-]+)\s*:\s*([^;{}]+)/gm)) { + if (!colorProperty.test(name) || value.includes("url(")) continue; + const literal = + /#[0-9a-f]{3,8}\b/i.test(value) || + /\b(?:rgba?|hsla?|hwb|lab|lch|oklab|oklch)\(\s*[\d.]/i.test(value) || + /var\(--[\w-]+\s*,\s*[\d.]+\s*,\s*[\d.]+\s*,\s*[\d.]+\s*\)/.test(value) || + value.split(/[\s,()]+/).some(keyword); + if (literal) found.push(`${name}: ${value.trim()}`); + } + return found; + }, + [css, COLOR_PROPERTY.source], + ); + expect(offenders, "every color of the role stylesheet comes from a --design-* token").toEqual([]); + }); + test("design: the sign-in page takes surface, primary action and text from the palette", async ({ page }) => { skipUnlessServiceEnabled("design"); for (const mode of MODES) { diff --git a/roles/web-app-mattermost/templates/style.css.j2 b/roles/web-app-mattermost/templates/style.css.j2 index 44c8b9c9db..e696ce6a22 100644 --- a/roles/web-app-mattermost/templates/style.css.j2 +++ b/roles/web-app-mattermost/templates/style.css.j2 @@ -7336,15 +7336,15 @@ html:not([data-design-app-theme="custom"]) .table-editor__row-remove { color: rgba(var(--center-channel-color-rgb), 0.56); } html:not([data-design-app-theme="custom"]) .SimulateAccessModal__rowChip--allow { - background: rgba(var(--online-indicator-rgb, 6, 167, 125), 0.16); + background: rgba(var(--online-indicator-rgb, var(--design-success-rgb)), 0.16); color: var(--online-indicator, var(--design-success)); } html:not([data-design-app-theme="custom"]) .SimulateAccessModal__rowChip--allow-saved { - background: rgba(var(--online-indicator-rgb, 6, 167, 125), 0.12); + background: rgba(var(--online-indicator-rgb, var(--design-success-rgb)), 0.12); color: var(--online-indicator, var(--design-success)); } html:not([data-design-app-theme="custom"]) .SimulateAccessModal__rowChip--deny { - background: rgba(var(--error-text-color-rgb, 210, 75, 78), 0.16); + background: rgba(var(--error-text-color-rgb, var(--design-danger-rgb)), 0.16); color: var(--error-text); } html:not([data-design-app-theme="custom"]) .SimulateAccessModal__rowChip--pending { @@ -7356,7 +7356,7 @@ html:not([data-design-app-theme="custom"]) .SimulateAccessModal__rowChip--not-ap color: rgba(var(--center-channel-color-rgb), 0.72); } html:not([data-design-app-theme="custom"]) .SimulateAccessModal__rowChip--mixed { - background: rgba(var(--away-indicator-rgb, 255, 188, 66), 0.18); + background: rgba(var(--away-indicator-rgb, var(--design-warning-rgb)), 0.18); color: var(--away-indicator, var(--design-warning)); } html:not([data-design-app-theme="custom"]) .SimulateAccessModal__rowChipIcon { @@ -7410,11 +7410,11 @@ html:not([data-design-app-theme="custom"]) .SimulateAccessModal__detailsPolicies color: rgba(var(--center-channel-color-rgb), 0.72); } html:not([data-design-app-theme="custom"]) .SimulateAccessModal__detailsPolicyOutcome--allow { - background: rgba(var(--online-indicator-rgb, 6, 167, 125), 0.16); + background: rgba(var(--online-indicator-rgb, var(--design-success-rgb)), 0.16); color: var(--online-indicator, var(--design-success)); } html:not([data-design-app-theme="custom"]) .SimulateAccessModal__detailsPolicyOutcome--deny { - background: rgba(var(--error-text-color-rgb, 210, 75, 78), 0.16); + background: rgba(var(--error-text-color-rgb, var(--design-danger-rgb)), 0.16); color: var(--error-text, var(--design-danger)); } html:not([data-design-app-theme="custom"]) .SimulateAccessModal__detailsPolicyBadge { @@ -7454,15 +7454,15 @@ html:not([data-design-app-theme="custom"]) .SimulateAccessModal__traceNode { } html:not([data-design-app-theme="custom"]) .SimulateAccessModal__traceNode--true { border-left-color: var(--online-indicator, var(--design-success)); - background: rgba(var(--online-indicator-rgb, 6, 167, 125), 0.06); + background: rgba(var(--online-indicator-rgb, var(--design-success-rgb)), 0.06); } html:not([data-design-app-theme="custom"]) .SimulateAccessModal__traceNode--false { border-left-color: var(--error-text, var(--design-danger)); - background: rgba(var(--error-text-color-rgb, 210, 75, 78), 0.06); + background: rgba(var(--error-text-color-rgb, var(--design-danger-rgb)), 0.06); } html:not([data-design-app-theme="custom"]) .SimulateAccessModal__traceNode--error { border-left-color: var(--away-indicator, var(--design-warning)); - background: rgba(var(--away-indicator-rgb, 255, 188, 66), 0.08); + background: rgba(var(--away-indicator-rgb, var(--design-warning-rgb)), 0.08); } html:not([data-design-app-theme="custom"]) .SimulateAccessModal__traceIcon--true { color: var(--online-indicator, var(--design-success)); @@ -7576,11 +7576,11 @@ html:not([data-design-app-theme="custom"]) .SimulateAccessModal__empty { color: rgba(var(--center-channel-color-rgb), 0.56); } html:not([data-design-app-theme="custom"]) .SimulateAccessModal__empty--error { - background: rgba(var(--error-text-color-rgb, 210, 75, 78), 0.04); + background: rgba(var(--error-text-color-rgb, var(--design-danger-rgb)), 0.04); color: var(--error-text, var(--design-danger)); } html:not([data-design-app-theme="custom"]) .SimulateAccessModal__simulateError { - background: rgba(var(--error-text-color-rgb, 210, 75, 78), 0.08); + background: rgba(var(--error-text-color-rgb, var(--design-danger-rgb)), 0.08); color: var(--error-text, var(--design-danger)); } html:not([data-design-app-theme="custom"]) .SimulateAccessModal__table th, @@ -7759,24 +7759,24 @@ html:not([data-design-app-theme="custom"]) .Help { color: var(--center-channel-color, var(--design-text)); } html:not([data-design-app-theme="custom"]) .Help__header { - border-bottom: 1px solid rgba(var(--center-channel-color-rgb, 61, 60, 64), 0.16); + border-bottom: 1px solid rgba(var(--center-channel-color-rgb, var(--design-text-rgb)), 0.16); } html:not([data-design-app-theme="custom"]) .Help__header h1 { color: var(--center-channel-color, var(--design-text)); } html:not([data-design-app-theme="custom"]) .Help__content>p { - color: rgba(var(--center-channel-color-rgb, 61, 60, 64), 0.8); + color: rgba(var(--center-channel-color-rgb, var(--design-text-rgb)), 0.8); } html:not([data-design-app-theme="custom"]) .Help__section h2, html:not([data-design-app-theme="custom"]) .Help__section h3 { color: var(--center-channel-color, var(--design-text)); } html:not([data-design-app-theme="custom"]) .Help__section p { - color: var(--center-channel-color-rgb, 61, 60, 64); + color: var(--center-channel-color, var(--design-text)); } html:not([data-design-app-theme="custom"]) .Help__section ul li, html:not([data-design-app-theme="custom"]) .Help__section ol li { - color: rgba(var(--center-channel-color-rgb, 61, 60, 64), 0.9); + color: rgba(var(--center-channel-color-rgb, var(--design-text-rgb)), 0.9); } html:not([data-design-app-theme="custom"]) .Help__section a, html:not([data-design-app-theme="custom"]) .Help__table td a { @@ -7784,32 +7784,32 @@ html:not([data-design-app-theme="custom"]) .Help__table td a { } html:not([data-design-app-theme="custom"]) .Help__table th, html:not([data-design-app-theme="custom"]) .Help__table td { - border: 1px solid rgba(var(--center-channel-color-rgb, 61, 60, 64), 0.16); + border: 1px solid rgba(var(--center-channel-color-rgb, var(--design-text-rgb)), 0.16); } html:not([data-design-app-theme="custom"]) .Help__code-block, html:not([data-design-app-theme="custom"]) .Help__code-preview { - border: 1px solid rgba(var(--center-channel-color-rgb, 61, 60, 64), 0.16); - background: rgba(var(--center-channel-color-rgb, 61, 60, 64), 0.04); + border: 1px solid rgba(var(--center-channel-color-rgb, var(--design-text-rgb)), 0.16); + background: rgba(var(--center-channel-color-rgb, var(--design-text-rgb)), 0.04); } html:not([data-design-app-theme="custom"]) .Help__code-block code { color: var(--center-channel-color, var(--design-text)); } html:not([data-design-app-theme="custom"]) .Help__code-line-number { - color: rgba(var(--center-channel-color-rgb, 61, 60, 64), 0.4); + color: rgba(var(--center-channel-color-rgb, var(--design-text-rgb)), 0.4); } html:not([data-design-app-theme="custom"]) .Help__code-keyword { color: var(--design-info); } html:not([data-design-app-theme="custom"]) .Help__inline-code { - background: rgba(var(--center-channel-color-rgb, 61, 60, 64), 0.08); + background: rgba(var(--center-channel-color-rgb, var(--design-text-rgb)), 0.08); } html:not([data-design-app-theme="custom"]) .Help__hr, html:not([data-design-app-theme="custom"]) .Help__learn-more { - border-top: 1px solid rgba(var(--center-channel-color-rgb, 61, 60, 64), 0.16); + border-top: 1px solid rgba(var(--center-channel-color-rgb, var(--design-text-rgb)), 0.16); } html:not([data-design-app-theme="custom"]) .Help__blockquote { - border-left: 4px solid rgba(var(--center-channel-color-rgb, 61, 60, 64), 0.2); - color: rgba(var(--center-channel-color-rgb, 61, 60, 64), 0.7); + border-left: 4px solid rgba(var(--center-channel-color-rgb, var(--design-text-rgb)), 0.2); + color: rgba(var(--center-channel-color-rgb, var(--design-text-rgb)), 0.7); } html:not([data-design-app-theme="custom"]) .Help__badge { background-color: var(--design-text); @@ -7825,8 +7825,8 @@ html:not([data-design-app-theme="custom"]) .Help__learn-more ul li a { color: var(--button-bg, var(--design-link)); } html:not([data-design-app-theme="custom"]) .Help__example-box { - border: 1px solid rgba(var(--center-channel-color-rgb, 61, 60, 64), 0.12); - background: rgba(var(--center-channel-color-rgb, 61, 60, 64), 0.02); + border: 1px solid rgba(var(--center-channel-color-rgb, var(--design-text-rgb)), 0.12); + background: rgba(var(--center-channel-color-rgb, var(--design-text-rgb)), 0.02); } html:not([data-design-app-theme="custom"]) .Help__example-content p { color: var(--center-channel-color, var(--design-text)); @@ -7839,25 +7839,25 @@ html:not([data-design-app-theme="custom"]) .Help__example-name { } html:not([data-design-app-theme="custom"]) .Help__example-time, html:not([data-design-app-theme="custom"]) .Help__commands-header { - color: rgba(var(--center-channel-color-rgb, 61, 60, 64), 0.6); + color: rgba(var(--center-channel-color-rgb, var(--design-text-rgb)), 0.6); } html:not([data-design-app-theme="custom"]) .Help__commands-example { - border: 1px solid rgba(var(--center-channel-color-rgb, 61, 60, 64), 0.16); + border: 1px solid rgba(var(--center-channel-color-rgb, var(--design-text-rgb)), 0.16); background-color: var(--design-surface-2); box-shadow: var(--design-shadow-2); } html:not([data-design-app-theme="custom"]) .Help__commands-list { - border-top: 1px solid rgba(var(--center-channel-color-rgb, 61, 60, 64), 0.08); + border-top: 1px solid rgba(var(--center-channel-color-rgb, var(--design-text-rgb)), 0.08); } html:not([data-design-app-theme="custom"]) .Help__command-icon { background-color: rgba(var(--design-text-rgb), 0.08); - color: rgba(var(--center-channel-color-rgb, 61, 60, 64), 0.7); + color: rgba(var(--center-channel-color-rgb, var(--design-text-rgb)), 0.7); } html:not([data-design-app-theme="custom"]) .Help__command-name { color: var(--center-channel-color, var(--design-text)); } html:not([data-design-app-theme="custom"]) .Help__command-desc { - color: rgba(var(--center-channel-color-rgb, 61, 60, 64), 0.6); + color: rgba(var(--center-channel-color-rgb, var(--design-text-rgb)), 0.6); } html:not([data-design-app-theme="custom"]) .userSettingDesktopHeader .userSettingDesktopHeaderInfo svg.circular-border { background: rgba(var(--button-bg-rgb), 0.12); @@ -8311,24 +8311,24 @@ html:not([data-design-app-theme="custom"]) .Help { color: var(--center-channel-color, var(--design-text)); } html:not([data-design-app-theme="custom"]) .Help__header { - border-bottom: 1px solid rgba(var(--center-channel-color-rgb, 61, 60, 64), 0.16); + border-bottom: 1px solid rgba(var(--center-channel-color-rgb, var(--design-text-rgb)), 0.16); } html:not([data-design-app-theme="custom"]) .Help__header h1 { color: var(--center-channel-color, var(--design-text)); } html:not([data-design-app-theme="custom"]) .Help__content>p { - color: rgba(var(--center-channel-color-rgb, 61, 60, 64), 0.8); + color: rgba(var(--center-channel-color-rgb, var(--design-text-rgb)), 0.8); } html:not([data-design-app-theme="custom"]) .Help__section h2, html:not([data-design-app-theme="custom"]) .Help__section h3 { color: var(--center-channel-color, var(--design-text)); } html:not([data-design-app-theme="custom"]) .Help__section p { - color: var(--center-channel-color-rgb, 61, 60, 64); + color: var(--center-channel-color, var(--design-text)); } html:not([data-design-app-theme="custom"]) .Help__section ul li, html:not([data-design-app-theme="custom"]) .Help__section ol li { - color: rgba(var(--center-channel-color-rgb, 61, 60, 64), 0.9); + color: rgba(var(--center-channel-color-rgb, var(--design-text-rgb)), 0.9); } html:not([data-design-app-theme="custom"]) .Help__section a, html:not([data-design-app-theme="custom"]) .Help__table td a { @@ -8336,32 +8336,32 @@ html:not([data-design-app-theme="custom"]) .Help__table td a { } html:not([data-design-app-theme="custom"]) .Help__table th, html:not([data-design-app-theme="custom"]) .Help__table td { - border: 1px solid rgba(var(--center-channel-color-rgb, 61, 60, 64), 0.16); + border: 1px solid rgba(var(--center-channel-color-rgb, var(--design-text-rgb)), 0.16); } html:not([data-design-app-theme="custom"]) .Help__code-block, html:not([data-design-app-theme="custom"]) .Help__code-preview { - border: 1px solid rgba(var(--center-channel-color-rgb, 61, 60, 64), 0.16); - background: rgba(var(--center-channel-color-rgb, 61, 60, 64), 0.04); + border: 1px solid rgba(var(--center-channel-color-rgb, var(--design-text-rgb)), 0.16); + background: rgba(var(--center-channel-color-rgb, var(--design-text-rgb)), 0.04); } html:not([data-design-app-theme="custom"]) .Help__code-block code { color: var(--center-channel-color, var(--design-text)); } html:not([data-design-app-theme="custom"]) .Help__code-line-number { - color: rgba(var(--center-channel-color-rgb, 61, 60, 64), 0.4); + color: rgba(var(--center-channel-color-rgb, var(--design-text-rgb)), 0.4); } html:not([data-design-app-theme="custom"]) .Help__code-keyword { color: var(--design-info); } html:not([data-design-app-theme="custom"]) .Help__inline-code { - background: rgba(var(--center-channel-color-rgb, 61, 60, 64), 0.08); + background: rgba(var(--center-channel-color-rgb, var(--design-text-rgb)), 0.08); } html:not([data-design-app-theme="custom"]) .Help__hr, html:not([data-design-app-theme="custom"]) .Help__learn-more { - border-top: 1px solid rgba(var(--center-channel-color-rgb, 61, 60, 64), 0.16); + border-top: 1px solid rgba(var(--center-channel-color-rgb, var(--design-text-rgb)), 0.16); } html:not([data-design-app-theme="custom"]) .Help__blockquote { - border-left: 4px solid rgba(var(--center-channel-color-rgb, 61, 60, 64), 0.2); - color: rgba(var(--center-channel-color-rgb, 61, 60, 64), 0.7); + border-left: 4px solid rgba(var(--center-channel-color-rgb, var(--design-text-rgb)), 0.2); + color: rgba(var(--center-channel-color-rgb, var(--design-text-rgb)), 0.7); } html:not([data-design-app-theme="custom"]) .Help__badge { background-color: var(--design-text); @@ -8377,8 +8377,8 @@ html:not([data-design-app-theme="custom"]) .Help__learn-more ul li a { color: var(--button-bg, var(--design-link)); } html:not([data-design-app-theme="custom"]) .Help__example-box { - border: 1px solid rgba(var(--center-channel-color-rgb, 61, 60, 64), 0.12); - background: rgba(var(--center-channel-color-rgb, 61, 60, 64), 0.02); + border: 1px solid rgba(var(--center-channel-color-rgb, var(--design-text-rgb)), 0.12); + background: rgba(var(--center-channel-color-rgb, var(--design-text-rgb)), 0.02); } html:not([data-design-app-theme="custom"]) .Help__example-content p { color: var(--center-channel-color, var(--design-text)); @@ -8391,25 +8391,25 @@ html:not([data-design-app-theme="custom"]) .Help__example-name { } html:not([data-design-app-theme="custom"]) .Help__example-time, html:not([data-design-app-theme="custom"]) .Help__commands-header { - color: rgba(var(--center-channel-color-rgb, 61, 60, 64), 0.6); + color: rgba(var(--center-channel-color-rgb, var(--design-text-rgb)), 0.6); } html:not([data-design-app-theme="custom"]) .Help__commands-example { - border: 1px solid rgba(var(--center-channel-color-rgb, 61, 60, 64), 0.16); + border: 1px solid rgba(var(--center-channel-color-rgb, var(--design-text-rgb)), 0.16); background-color: var(--design-surface-2); box-shadow: var(--design-shadow-2); } html:not([data-design-app-theme="custom"]) .Help__commands-list { - border-top: 1px solid rgba(var(--center-channel-color-rgb, 61, 60, 64), 0.08); + border-top: 1px solid rgba(var(--center-channel-color-rgb, var(--design-text-rgb)), 0.08); } html:not([data-design-app-theme="custom"]) .Help__command-icon { background-color: rgba(var(--design-text-rgb), 0.08); - color: rgba(var(--center-channel-color-rgb, 61, 60, 64), 0.7); + color: rgba(var(--center-channel-color-rgb, var(--design-text-rgb)), 0.7); } html:not([data-design-app-theme="custom"]) .Help__command-name { color: var(--center-channel-color, var(--design-text)); } html:not([data-design-app-theme="custom"]) .Help__command-desc { - color: rgba(var(--center-channel-color-rgb, 61, 60, 64), 0.6); + color: rgba(var(--center-channel-color-rgb, var(--design-text-rgb)), 0.6); } html:not([data-design-app-theme="custom"]) .DesktopAuthToken__main { color: var(--sidebar-header-bg); @@ -8643,7 +8643,7 @@ html:not([data-design-app-theme="custom"]) .ChannelSettingsModal__permissionsPol color: var(--button-bg); } html:not([data-design-app-theme="custom"]) .ChannelSettingsModal__permissionsPolicyError { - background-color: rgba(var(--error-text-color-rgb, 210, 75, 78), 0.08); + background-color: rgba(var(--error-text-color-rgb, var(--design-danger-rgb)), 0.08); color: var(--error-text); } html:not([data-design-app-theme="custom"]) .ChannelSettingsModal__infoTabTitle { diff --git a/roles/web-app-shopware/files/playwright/test-design.js b/roles/web-app-shopware/files/playwright/test-design.js index 3311c053b9..f9a88a5177 100644 --- a/roles/web-app-shopware/files/playwright/test-design.js +++ b/roles/web-app-shopware/files/playwright/test-design.js @@ -13,6 +13,7 @@ const { } = require("./design"); const { apiFetchOnion, + apiGetOnion, decodeDotenvQuotedValue, gotoOnion, normalizeBaseUrl, @@ -52,6 +53,9 @@ const THEME_FIELDS = [ ["sw-color-danger", "--design-danger"], ]; const INJECTED_SHEET = /\/_shared\/css\/|\/style\.css(\?|$)/; +const ROLE_SHEET = "link[href*='/roles/web-app-shopware/'][href*='style.css']"; +const COLOR_PROPERTY = + /^(?:--[\w-]+|(?:[a-z-]*-)?color|background(?:-image)?|fill|stroke|box-shadow|text-shadow|border(?:-(?:top|right|bottom|left))?|outline|column-rule)$/; async function adminApi(request, token, method, path, data) { const response = await apiFetchOnion(request, `${base}${path}`, { @@ -542,6 +546,45 @@ test("design: the storefront shows the configured logo, favicon and shop name", expect(await page.title(), "the page title carries the shop name of the design").toContain(designTitle); }); +test("design: the role stylesheet sets every color through a token, also inside its SVG images", async ({ page }) => { + skipUnlessServiceEnabled("design"); + await openStorefront(page, "/account/login", "form.login-form"); + const sheet = await page.locator(ROLE_SHEET).first().evaluate((link) => link.href); + const css = await (await apiGetOnion(page.request, sheet)).text(); + const offenders = await page.evaluate( + ([source, property]) => { + const colorProperty = new RegExp(property); + const keyword = (word) => + /^[a-z]+$/i.test(word) && + !/^(?:transparent|currentcolor|inherit|initial|unset|revert|none)$/i.test(word) && + CSS.supports("color", word); + const literal = (value) => + /#[0-9a-f]{3,8}\b/i.test(value) || + /\b(?:rgba?|hsla?|hwb|lab|lch|oklab|oklch)\(\s*[\d.]/i.test(value) || + value.split(/[\s,()]+/).some(keyword); + const found = []; + const images = + /url\(\s*(?:"(data:image\/svg\+xml[^"]*)"|'(data:image\/svg\+xml[^']*)'|(data:image\/svg\+xml[^)]*))\s*\)/gi; + for (const match of source.matchAll(images)) { + const uri = match[1] || match[2] || match[3]; + const svg = uri.includes(";base64,") + ? atob(uri.slice(uri.indexOf(";base64,") + 8)) + : decodeURIComponent(uri.slice(uri.indexOf(",") + 1)); + for (const [, attribute, value] of svg.matchAll(/\b(fill|stroke|stop-color|flood-color)\s*[=:]\s*["']?([^"';\s>)]+)/gi)) { + if (literal(value)) found.push(`svg ${attribute}=${value}`); + } + } + const declarations = source.replace(/url\((?:[^()"']|"[^"]*"|'[^']*')*\)/gi, "url()"); + for (const [, name, value] of declarations.matchAll(/(?:^|[{;])\s*(--[\w-]+|[a-z-]+)\s*:\s*([^;{}]+)/gm)) { + if (colorProperty.test(name) && literal(value)) found.push(`${name}: ${value.trim()}`); + } + return found; + }, + [css, COLOR_PROPERTY.source], + ); + expect(offenders, "every color of the role stylesheet, also inside its SVG images, comes from a --design-* token").toEqual([]); +}); + test("design: the administration maps its tokens and keeps the menu on the frame", async ({ page, request }) => { skipUnlessServiceEnabled("design"); expect( diff --git a/roles/web-app-shopware/templates/style.css.j2 b/roles/web-app-shopware/templates/style.css.j2 index 35a784ba52..84029039bb 100644 --- a/roles/web-app-shopware/templates/style.css.j2 +++ b/roles/web-app-shopware/templates/style.css.j2 @@ -762,12 +762,6 @@ body { .carousel-control-next:focus { color: var(--design-surface-1); } -.carousel-control-prev-icon { - background-image: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16' fill='%23fff'%3e%3cpath d='M11.354 1.646a.5.5 0 0 1 0 .708L5.707 8l5.647 5.646a.5.5 0 0 1-.708.708l-6-6a.5.5 0 0 1 0-.708l6-6a.5.5 0 0 1 .708 0'/%3e%3c/svg%3e"); -} -.carousel-control-next-icon { - background-image: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16' fill='%23fff'%3e%3cpath d='M4.646 1.646a.5.5 0 0 1 .708 0l6 6a.5.5 0 0 1 0 .708l-6 6a.5.5 0 0 1-.708-.708L10.293 8 4.646 2.354a.5.5 0 0 1 0-.708'/%3e%3c/svg%3e"); -} .carousel-caption { color: var(--bs-carousel-caption-color); } @@ -1625,7 +1619,6 @@ html .sw-text-editor-table .sw-text-editor-table__col { } .filter-property-select-preview { border-color: var(--design-border-strong); - background-image: url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 90 90'%3E%3Cpath fill='%23cdd5db' d='M0 0h30v30H0zM30 30h30v30H30zM60 0h30v30H60zM60 60h30v30H60zM0 60h30v30H0z'/%3E%3C/svg%3E"); } .filter-property-select-preview-checkmark { background-color: var(--design-primary); @@ -2709,14 +2702,14 @@ html .sw-alert--system .sw-alert__message { color: var(--design-on-primary); } html .sw-button.sw-button--contrast { - background: gold; - color: var(--design-text-muted); + background: var(--design-warning); + color: var(--design-on-warning); } html .sw-button.sw-button--contrast .mt-icon { - color: var(--design-text-muted); + color: var(--design-on-warning); } html .sw-button.sw-button--contrast .sw-loader .sw-loader-element div { - border-color: var(--design-border) transparent transparent transparent; + border-color: var(--design-on-warning) transparent transparent transparent; } html .sw-button.sw-button--contrast:hover { background-color: var(--design-warning-hover); @@ -2759,9 +2752,6 @@ html .sw-admin-menu.is--expanded .sw-admin-menu__navigation-link.router-link-act html .sw-admin-menu .sw-admin-menu__headline { color: var(--design-on-frame); } -html .sw-admin-menu .sw-admin-menu__header-logo { - background: url(data:image/svg+xml;base64,PD94bWwgdmVyc2lvbj0iMS4wIiBlbmNvZGluZz0iVVRGLTgiPz4KPHN2ZyB3aWR0aD0iMzRweCIgaGVpZ2h0PSIzNHB4IiB2aWV3Qm94PSIwIDAgMzQgMzQiIHZlcnNpb249IjEuMSIgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIiB4bWxuczp4bGluaz0iaHR0cDovL3d3dy53My5vcmcvMTk5OS94bGluayI+CiAgICA8IS0tIEdlbmVyYXRvcjogU2tldGNoIDQ2LjIgKDQ0NDk2KSAtIGh0dHA6Ly93d3cuYm9oZW1pYW5jb2RpbmcuY29tL3NrZXRjaCAtLT4KICAgIDx0aXRsZT5Mb2dvPC90aXRsZT4KICAgIDxkZXNjPkNyZWF0ZWQgd2l0aCBTa2V0Y2guPC9kZXNjPgogICAgPGRlZnM+CiAgICAgICAgPHBhdGggZD0iTTI2LjkyNjc3MiwzMC4wNzA0NTYyIEMyNi45ODc1MjA3LDMwLjExNjE3MTEgMjcuMDIzNzI0NSwzMC4xODg4ODU1IDI3LjAyMzQxNzcsMzAuMjY1MjgxNiBDMjcuMDIzMTEwOSwzMC4zNDEzNzA5IDI2Ljk4NjI5MzUsMzAuNDEzNzc4NCAyNi45MjUyMzc5LDMwLjQ1OTE4NjUgQzI0LjAwMTAxNjEsMzIuNjIzNDM1OSAyMC41MjkxMzU1LDMzLjc2NzIyOTcgMTYuODg1MTMzNiwzMy43NjcyMjk3IEMxMi4zNzQzODg3LDMzLjc2NzIyOTcgOC4xMzQyNTE3NywzMi4wMTEzNDY3IDQuOTQ1MjUxNSwyOC44MjI5NiBDMS43NTYyNTEyMywyNS42MzQ1NzM0IDYuMTM2MjMyOTZlLTA1LDIxLjM5NDQzNjQgNi4xMzYyMzI5NmUtMDUsMTYuODgzOTk4NCBDNi4xMzYyMzI5NmUtMDUsMTIuMzc0MTc0IDEuNzU2MjUxMjMsOC4xMzQzNDM4MSA0Ljk0NTI1MTUsNC45NDUzNDM1NCBDOC4xMzQyNTE3NywxLjc1NjM0MzI4IDEyLjM3NDY5NTUsMC4wMDAxNTM0MDU4MjQgMTYuODg1MTMzNiwwLjAwMDE1MzQwNTgyNCBDMjEuMDU4Mzg1NiwwLjAwMDE1MzQwNTgyNCAyNS4wNjQxMTg1LDEuNTM1NDM4ODkgMjguMTY0NDUwMiw0LjMyMzEyOTUyIEMyOC4yNDQyMjEyLDQuMzk0NjE2NjQgMjguMjY3MjMyMSw0LjUwODQ0Mzc2IDI4LjIyMjEzMDgsNC42MDUzOTYyNCBDMjguMTc4MjU2Nyw0LjcwMDIwMTA0IDI4LjA3NjA4ODQsNC43NTc4ODE2MyAyNy45Njk5MzE2LDQuNzQzNzY4MjkgQzI2Ljc2NjkyMzEsNC41Nzg3MDM2MiAyNS40NjgxODk0LDQuNDk0OTQ0MDQgMjQuMTA5MzIwNiw0LjQ5NDk0NDA0IEMxOS4xOTE3NDM1LDQuNDk0OTQ0MDQgMTUuMTk4ODk2OCw1LjU5MTQ4ODg3IDEyLjU2MjQ2NDMsNy42NjY3NjI4NiBDMTAuMjExMzY2Niw5LjUxNzE0MzkgOC45Njg3Nzk0NSwxMi4wNzc0ODcxIDguOTY4Nzc5NDUsMTUuMDcxMDQ4NCBDOC45Njg3Nzk0NSwxOC40MDg1NDU1IDEwLjI0NjAzNjMsMjEuMDQ3MTI1NiAxMi44NzM1NzEzLDIzLjEzODM1MzggQzE1LjE5Mjc2MDUsMjQuOTgzODI1OSAxOC4yODE3NDAyLDI2LjE4MTkyNTQgMjEuMDA3MTQ4MSwyNy4yMzg1ODQ3IEMyMy4yOTY4ODM0LDI4LjEyNjgwNDQgMjUuNDU5NTk4NywyOC45NjUzMjA2IDI2LjkyNjc3MiwzMC4wNzA0NTYyIFogTTMzLjY5NjQxNzYsMTUuMzIzODYxMSBDMzMuNzQzMzU5OCwxNS44NjEwODgzIDMzLjc2Njk4NDMsMTYuMzg2MDQzMSAzMy43NjY5ODQzLDE2Ljg4MzY5MTYgQzMzLjc2Njk4NDMsMTkuMDAzMTQ2NCAzMy4zNzgyNTM5LDIxLjA3MzgxODIgMzIuNjExNTMxNiwyMy4wMzgzMzMyIEMzMi41ODYwNjYyLDIzLjEwMzM3NzMgMzIuNTMzNjAxNSwyMy4xNTQzMDggMzIuNDY3OTQzOCwyMy4xNzgyMzkzIEMzMi40NDE1NTgsMjMuMTg3NzUwNSAzMi40MTM5NDQ5LDIzLjE5MjM1MjcgMzIuMzg1NzE4MiwyMy4xOTIzNTI3IEMzMi4zNDQ2MDU1LDIzLjE5MjM1MjcgMzIuMzAzNzk5NSwyMy4xODE5MjExIDMyLjI2NzkwMjYsMjMuMTYxOTc4MyBDMzAuMjYwNDM0LDIyLjA0ODI1MiAyOC4wNzM0ODA1LDIxLjIzMjc0NjcgMjUuOTU4NjI3OCwyMC40NDM5MzM5IEMyMS44NTQ0MDg0LDE4LjkxMzU1NzQgMTguMzEwNzMzOSwxNy41OTE4MTI4IDE4LjMxMDczMzksMTQuNjkxODI5MiBDMTguMzEwNzMzOSwxMy42NTY2NDY3IDE4Ljc0MzY0NTEsMTIuNzc1MTc2OCAxOS41NjMxMzksMTIuMTQyMjI0NCBDMjAuNTA5MzQ2MiwxMS40MTEzOTkgMjEuOTI1ODk1NSwxMS4wNDA3NzA1IDIzLjc3MzgyMjEsMTEuMDQwNzcwNSBDMjcuMjM2NDk4NCwxMS4wNDA3NzA1IDMwLjA4NjE2NDksMTIuMjMzMzQ3NCAzMy42MDk1ODk5LDE1LjE1Nzg3NiBDMzMuNjU5MjkzNCwxNS4xOTg5ODg4IDMzLjY5MDg5NSwxNS4yNTk3Mzc1IDMzLjY5NjQxNzYsMTUuMzIzODYxMSBaIiBpZD0icGF0aC0xIj48L3BhdGg+CiAgICA8L2RlZnM+CiAgICA8ZyBpZD0iU3ltYm9scyIgc3Ryb2tlPSJub25lIiBzdHJva2Utd2lkdGg9IjEiIGZpbGw9Im5vbmUiIGZpbGwtcnVsZT0iZXZlbm9kZCI+CiAgICAgICAgPGcgaWQ9Ik5hdi1Mb2dvL0xhcmdlIj4KICAgICAgICAgICAgPGcgaWQ9IkxvZ28iPgogICAgICAgICAgICAgICAgPGcgaWQ9IkZpbGwtMyI+CiAgICAgICAgICAgICAgICAgICAgPGc+CiAgICAgICAgICAgICAgICAgICAgICAgIDxnIGlkPSJMb2dvL1NpZ25ldCI+CiAgICAgICAgICAgICAgICAgICAgICAgICAgICA8bWFzayBpZD0ibWFzay0yIiBmaWxsPSJ3aGl0ZSI+CiAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgPHVzZSB4bGluazpocmVmPSIjcGF0aC0xIj48L3VzZT4KICAgICAgICAgICAgICAgICAgICAgICAgICAgIDwvbWFzaz4KICAgICAgICAgICAgICAgICAgICAgICAgICAgIDx1c2UgaWQ9Ik1hc2siIGZpbGw9IiNGRkZGRkYiIGZpbGwtcnVsZT0iZXZlbm9kZCIgeGxpbms6aHJlZj0iI3BhdGgtMSI+PC91c2U+CiAgICAgICAgICAgICAgICAgICAgICAgIDwvZz4KICAgICAgICAgICAgICAgICAgICA8L2c+CiAgICAgICAgICAgICAgICA8L2c+CiAgICAgICAgICAgIDwvZz4KICAgICAgICA8L2c+CiAgICA8L2c+Cjwvc3ZnPg==) no-repeat; -} html .sw-admin-menu .sw-admin-menu__headline-action, html .sw-admin-menu .sw-admin-menu__headline-context-menu { color: var(--design-on-frame); @@ -2804,9 +2794,6 @@ html .sw-admin-menu .sw-admin-menu__user-actions-toggle.is--active { html .sw-admin-menu .sw-admin-menu__user-actions-toggle .sw-admin-menu__navigation-list-item:hover { background-color: var(--design-frame-hover); } -html .sw-admin-menu .sw-admin-menu__user-type { - color: gold; -} html .sw-admin-menu .sw-admin-menu__user-actions .sw-admin-menu__navigation-list-item { background-color: var(--design-frame-active); } diff --git a/tasks/utils/setup/role.yml b/tasks/utils/setup/role.yml index 26ca195d3b..04a4af1cd8 100644 --- a/tasks/utils/setup/role.yml +++ b/tasks/utils/setup/role.yml @@ -8,3 +8,6 @@ - name: "๐Ÿšฟ flush handlers after {{ role_entry.app }}" ansible.builtin.meta: flush_handlers + +- name: "๐ŸงŠ Carry the applications SPOT past {{ role_entry.app }}" + applications_carrier: {} diff --git a/tests/unit/python/plugins/action/test_applications_carrier.py b/tests/unit/python/plugins/action/test_applications_carrier.py index 841b2eea44..9f69c1d1d5 100644 --- a/tests/unit/python/plugins/action/test_applications_carrier.py +++ b/tests/unit/python/plugins/action/test_applications_carrier.py @@ -56,6 +56,48 @@ class TestApplicationsCarrierActionPlugin(unittest.TestCase): self.assertEqual(variables, {"applications": {}}) self.assertTrue(kwargs["carrier"]) + def test_revalidates_the_tokens_file_before_rendering(self): + order = [] + lookup = _FakeLookup({"key": ["roles", []], "applications": {}}) + lookup.run = lambda *args, **kwargs: order.append("render") or [{}] + action = _make_action(_FakeTask()) + with ( + patch( + "plugins.action.applications_carrier.ActionBase.run", + autospec=True, + return_value={}, + ), + patch( + "plugins.action.applications_carrier.lookup_loader.get", + return_value=lookup, + ), + patch( + "plugins.action.applications_carrier.revalidate_tokens_file", + side_effect=lambda variables: order.append(("revalidate", variables)), + ), + ): + action.run(task_vars={"FILE_TOKENS": "/x/tokens.yml"}) + + self.assertEqual( + order, [("revalidate", {"FILE_TOKENS": "/x/tokens.yml"}), "render"] + ) + + def test_clear_does_not_revalidate(self): + action = _make_action(_FakeTask(args={"clear": True})) + with ( + patch( + "plugins.action.applications_carrier.ActionBase.run", + autospec=True, + return_value={}, + ), + patch( + "plugins.action.applications_carrier.revalidate_tokens_file" + ) as revalidate, + ): + action.run(task_vars={}) + + revalidate.assert_not_called() + def test_rejects_arguments(self): action = _make_action(_FakeTask(args={"foo": 1})) with ( diff --git a/tests/unit/python/roles/svc-ai-litellm/templates/test_config_model_list.py b/tests/unit/python/roles/svc-ai-litellm/templates/test_config_model_list.py index 2ac09cba76..f2b8c11423 100644 --- a/tests/unit/python/roles/svc-ai-litellm/templates/test_config_model_list.py +++ b/tests/unit/python/roles/svc-ai-litellm/templates/test_config_model_list.py @@ -74,6 +74,7 @@ def render( remote_models=REMOTE_MODELS, router_alias="", measured_speed=None, + logo_url="", ): """The rendered config as a parsed mapping. @@ -86,6 +87,7 @@ def render( is what every case that predates the router expects. measured_speed: alias -> tokens per second from the last deploy's measurement; empty means nothing has been measured yet. + logo_url: LITELLM_UI_LOGO_URL; empty while the admin UI shows no corporate logo. """ env = Environment( undefined=StrictUndefined, @@ -108,6 +110,7 @@ def render( + [MOCK["provider"]], LITELLM_ROUTER_ALIAS=router_alias, LITELLM_MEASURED_SPEED=measured_speed or {}, + LITELLM_UI_LOGO_URL=logo_url, ) return yaml.safe_load( rendered @@ -183,6 +186,17 @@ class TestBothBackends(unittest.TestCase): ) +class TestUiTheme(unittest.TestCase): + def test_a_logo_url_reaches_the_ui_theme_config(self) -> None: + url = "https://cdn.example/roles/web-app-litellm/latest/img/design/logo.svg" + settings = render(logo_url=url)["litellm_settings"] + self.assertEqual(settings["ui_theme_config"], {"logo_url": url}) + self.assertTrue(settings["drop_params"]) + + def test_without_a_logo_url_the_ui_theme_config_is_absent(self) -> None: + self.assertNotIn("ui_theme_config", render()["litellm_settings"]) + + class TestNoBackend(unittest.TestCase): def test_the_model_list_is_empty(self) -> None: self.assertEqual(render()["model_list"], []) diff --git a/tests/unit/python/utils/cache/test_base.py b/tests/unit/python/utils/cache/test_base.py index 044cd168e9..657fbbd12d 100644 --- a/tests/unit/python/utils/cache/test_base.py +++ b/tests/unit/python/utils/cache/test_base.py @@ -10,9 +10,11 @@ files; this file pins the contract of base itself. from __future__ import annotations +import os import tempfile import textwrap import unittest +import unittest.mock as mock from pathlib import Path from utils.cache import _reset_cache_for_tests, base @@ -153,11 +155,13 @@ class TestStableVariablesSignature(unittest.TestCase): class TestTokensFileSignature(unittest.TestCase): - def test_missing_file_returns_zeroed_signature(self): + def setUp(self): + base._reset() + + def test_missing_file_returns_an_empty_digest(self): with tempfile.TemporaryDirectory() as tmp: - sig = base._tokens_file_signature(Path(tmp) / "absent.yml") - self.assertEqual(sig[1], 0) - self.assertEqual(sig[2], 0) + path = Path(tmp) / "absent.yml" + self.assertEqual(base._tokens_file_signature(path), (str(path), "")) def test_signature_changes_when_file_changes(self): with tempfile.TemporaryDirectory() as tmp: @@ -168,6 +172,37 @@ class TestTokensFileSignature(unittest.TestCase): sig_after = base._tokens_file_signature(path) self.assertNotEqual(sig_before, sig_after) + def test_a_touch_keeps_the_signature(self): + with tempfile.TemporaryDirectory() as tmp: + path = Path(tmp) / "tokens.yml" + path.write_text("a: 1\n", encoding="utf-8") + sig_before = base._tokens_file_signature(path) + later = path.stat().st_mtime_ns + 5_000_000_000 + os.utime(path, ns=(later, later)) + self.assertEqual(base._tokens_file_signature(path), sig_before) + + def test_an_unchanged_stat_serves_the_digest_without_reading(self): + with tempfile.TemporaryDirectory() as tmp: + path = Path(tmp) / "tokens.yml" + path.write_text("a: 1\n", encoding="utf-8") + sig_before = base._tokens_file_signature(path) + with mock.patch.object(Path, "open", side_effect=AssertionError): + self.assertEqual(base._tokens_file_signature(path), sig_before) + + +class TestRevalidateTokensFile(unittest.TestCase): + def test_opens_the_file(self): + with tempfile.TemporaryDirectory() as tmp: + path = Path(tmp) / "tokens.yml" + path.write_text("a: 1\n", encoding="utf-8") + with mock.patch.object(Path, "open", wraps=path.open) as opened: + base._revalidate_tokens_file(path) + opened.assert_called_once_with("rb") + + def test_a_missing_file_is_ignored(self): + with tempfile.TemporaryDirectory() as tmp: + base._revalidate_tokens_file(Path(tmp) / "absent.yml") + class TestRenderWithTemplar(unittest.TestCase): """`_render_with_templar` is the single ansible-coupled symbol in diff --git a/utils/cache/README.md b/utils/cache/README.md index 67d6fd11f9..d62c5e0e2d 100644 --- a/utils/cache/README.md +++ b/utils/cache/README.md @@ -26,7 +26,7 @@ The layers are orthogonal: `yaml.py` caches "this file's parsed root", the `appl Both layers are process-wide in-process caches. They intentionally do NOT track on-disk file mtimes; CLI tools are short-lived and the assumption "the on-disk file does not change while my process runs" holds. The single exception is `utils.cache.yaml.dump_yaml`, which writes through and evicts the cached entry for the path it just wrote so a tool that edits a file mid-run sees the new content on the next read. -Inside a play the process boundary is the task: Ansible forks a fresh worker per task, so `_MERGED_APPLICATIONS_CACHE` starts empty in every worker. `get_merged_applications` therefore also accepts the play-scoped carrier fact `_INFINITO_APPLICATIONS_RENDERED` (payload plus cache key, parked once by the constructor stage) and serves it when the key matches. See [applications.md](../../docs/contributing/artefact/files/plugins/lookup/applications.md) for the contract. +Inside a play the process boundary is the task: Ansible forks a fresh worker per task, so `_MERGED_APPLICATIONS_CACHE` starts empty in every worker. `get_merged_applications` therefore also accepts the play-scoped carrier fact `_INFINITO_APPLICATIONS_RENDERED` (payload plus cache key, parked by the constructor stage and again after every role and every persisted token) and serves it when the key matches. See [applications.md](../../docs/contributing/artefact/files/plugins/lookup/applications.md) for the contract. ## Test-Only Helpers ๐Ÿงช diff --git a/utils/cache/base.py b/utils/cache/base.py index b80f6fa814..0face6c0a8 100644 --- a/utils/cache/base.py +++ b/utils/cache/base.py @@ -16,10 +16,12 @@ only when actually invoked. from __future__ import annotations import copy +import hashlib +import os import threading from collections.abc import Mapping from pathlib import Path -from typing import TYPE_CHECKING, Any +from typing import Any from plugins.filter.merge.with_defaults import ( merge_with_defaults, # noqa: F401 re-exported @@ -29,8 +31,7 @@ from utils.paths import FILE_TOKENS from . import PROJECT_ROOT, ROLES_DIR # noqa: F401 -if TYPE_CHECKING: - import os +_TOKENS_DIGESTS: dict[tuple, str] = {} try: from ansible.parsing.vault import EncryptedString as _AnsibleEncryptedString @@ -126,18 +127,46 @@ def _stable_variables_signature(variables: Mapping[str, Any] | None) -> tuple: ) -def _tokens_file_signature(path: Path) -> tuple: - """Return a cheap stat-based signature for the tokens file. +def _stat_identity(st: os.stat_result) -> tuple: + return (st.st_ino, st.st_mtime_ns, st.st_size) - The merged-users cache must invalidate whenever sys-token-store persists a - new token โ€” otherwise downstream `lookup('users', ...)` returns stale tokens - within the same play. stat() is cheap and captures in-place writes. + +def _tokens_file_signature(path: Path) -> tuple: + """Return ``(path, sha256 of the content)`` for the tokens file. + + Args: + path: the tokens file. + + Returns: + ``(str(path), "")`` when the file cannot be read. """ try: - st = path.stat() - except (FileNotFoundError, OSError): - return (str(path), 0, 0) - return (str(path), st.st_mtime_ns, st.st_size) + identity = (str(path), *_stat_identity(path.stat())) + except OSError: + return (str(path), "") + digest = _TOKENS_DIGESTS.get(identity) + if digest is None: + try: + with path.open("rb") as handle: + identity = (str(path), *_stat_identity(os.fstat(handle.fileno()))) + digest = hashlib.sha256(handle.read()).hexdigest() + except OSError: + return (str(path), "") + _TOKENS_DIGESTS[identity] = digest + return (str(path), digest) + + +def _revalidate_tokens_file(path: Path) -> None: + """Open and close the tokens file so an NFS client refreshes its attributes. + + Args: + path: the tokens file. + """ + try: + with path.open("rb"): + pass + except OSError: + return def _deep_merge(base: Any, override: Any) -> Any: @@ -294,5 +323,5 @@ def _render_with_templar( def _reset() -> None: - """No-op kept so the `data._reset_cache_for_tests` facade can call every - domain module uniformly. This module owns no cache.""" + """Clear the tokens-file digest memo.""" + _TOKENS_DIGESTS.clear() diff --git a/utils/cache/carrier.py b/utils/cache/carrier.py index dfda350ae4..aafed31415 100644 --- a/utils/cache/carrier.py +++ b/utils/cache/carrier.py @@ -16,6 +16,7 @@ from typing import TYPE_CHECKING, Any from .base import ( _cache_key, _resolve_roles_dir, + _revalidate_tokens_file, _stable_variables_signature, _tokens_file_signature, ) @@ -42,7 +43,7 @@ def merged_applications_cache_key( Returns: ``(resolved roles dir, stable variables signature extended by the - tokens file's path, mtime and size)``. + tokens file's path and content digest)``. """ from .users import _resolve_tokens_file @@ -53,6 +54,17 @@ def merged_applications_cache_key( ) +def revalidate_tokens_file(variables: Mapping[str, Any] | None) -> None: + """Refresh an NFS client's attributes of the tokens file before a carrier is keyed. + + Args: + variables: The play's variable scope the tokens file resolves from. + """ + from .users import _resolve_tokens_file + + _revalidate_tokens_file(_resolve_tokens_file(variables)) + + def _carried_applications( variables: Mapping[str, Any], cache_key: tuple[str, tuple] ) -> dict[str, Any] | None: