From 523e8b37247ad8a65536343871dc8ba04c425603 Mon Sep 17 00:00:00 2001 From: Lilleman auf Larv Date: Sun, 8 Nov 2026 19:25:57 +0100 Subject: [PATCH] =?UTF-8?q?Answer=20stability=20round=201:=201=20?= =?UTF-8?q?=F0=9F=94=B4,=203=20=F0=9F=9F=A1=20(0=20misread);=20scope=20the?= =?UTF-8?q?=20visual=20selector,=20check=20label=20and=20cover=20the=20gap?= =?UTF-8?q?s?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- e2e-tests/visual.spec.ts | 2 +- src/plugin-host/discovery.test.ts | 4 ++++ src/plugin-host/discovery.ts | 1 + src/plugin-host/plugin-api.ts | 2 +- src/plugin-host/plugin.test.ts | 2 +- src/ui/chrome.test.ts | 6 +++--- 6 files changed, 11 insertions(+), 6 deletions(-) diff --git a/e2e-tests/visual.spec.ts b/e2e-tests/visual.spec.ts index 11f0f8f..83751c5 100644 --- a/e2e-tests/visual.spec.ts +++ b/e2e-tests/visual.spec.ts @@ -312,7 +312,7 @@ test("the reference plugin: public Overview is open to all, My shifts takes any await expect(page.locator('.sidebar a[href="/dashboard"]')).toHaveCount(1); // the one unified menu renders await expect(page.locator('.sidebar a[href="/scheduling"]')).toHaveCount(1); // public Overview shown await expect(page.locator('.sidebar a[href="/scheduling/shifts"]')).toHaveCount(0); // gated leaf filtered out - await expect(page.locator('.sidebar a[href="/scheduling/mine"]')).toHaveCount(1); // session gate: a session is enough + await expect(page.locator('.sidebar nav a[href="/scheduling/mine"]')).toHaveCount(1); // session gate: a session is enough // No shifts upstream on this stack, so this also pins the degraded page: the reason, never a 500 // and never a claim about what is assigned. diff --git a/src/plugin-host/discovery.test.ts b/src/plugin-host/discovery.test.ts index 3a24e44..cc85f21 100644 --- a/src/plugin-host/discovery.test.ts +++ b/src/plugin-host/discovery.test.ts @@ -77,6 +77,10 @@ const badCases: Array<{ name: string; files: Record; match: RegE { name: "a profile-menu entry naming no gate", files: { "nogateprofile/plugin.ts": `export default { apiVersion: "${HOST_API_VERSION}", profileMenu: [{ href: "/x", label: "X" }] };` }, match: /nogateprofile.*profile-menu entry "X".*names no gate/s }, { name: "a profile-menu entry gating on a bare word", files: { "bareprofile/plugin.ts": `export default { apiVersion: "${HOST_API_VERSION}", profileMenu: [{ href: "/x", label: "X", permission: "admin" }] };` }, match: /bareprofile.*admin.*:/s }, { name: "a profile-menu entry without an href", files: { "nohrefprofile/plugin.ts": `export default { apiVersion: "${HOST_API_VERSION}", profileMenu: [{ label: "X", session: true }] };` }, match: /nohrefprofile.*profile-menu entry "X".*href/s }, + { name: "a profile-menu entry with an empty href", files: { "emptyhref/plugin.ts": `export default { apiVersion: "${HOST_API_VERSION}", profileMenu: [{ href: "", label: "X", session: true }] };` }, match: /emptyhref.*needs an href/s }, + { name: "a profile-menu entry without a label", files: { "nolabel/plugin.ts": `export default { apiVersion: "${HOST_API_VERSION}", profileMenu: [{ href: "/x", session: true }] };` }, match: /nolabel.*needs a label/s }, + { name: "an ungated profile-menu entry after a valid one", files: { "secondbad/plugin.ts": `export default { apiVersion: "${HOST_API_VERSION}", profileMenu: [{ href: "/a", label: "A", session: true }, { href: "/b", label: "B" }] };` }, match: /secondbad.*"B".*names no gate/s }, + { name: "a null profile-menu entry", files: { "nullentry/plugin.ts": `export default { apiVersion: "${HOST_API_VERSION}", profileMenu: [null] };` }, match: /nullentry.*needs an href/s }, { name: "profileMenu that is not an array", files: { "objprofile/plugin.ts": `export default { apiVersion: "${HOST_API_VERSION}", profileMenu: {} };` }, match: /objprofile.*"profileMenu" must be an array/s }, { name: "a nav node gating on a bare word", files: { "barenav/plugin.ts": `export default { apiVersion: "${HOST_API_VERSION}", nav: [{ id: "n", label: "N", permission: "admin" }] };` }, match: /barenav.*admin.*:/s }, { name: "a declared permission that is a bare word", files: { "baredecl/plugin.ts": `export default { apiVersion: "${HOST_API_VERSION}", permissions: [{ name: "admin" }] };` }, match: /baredecl.*admin.*:/s }, diff --git a/src/plugin-host/discovery.ts b/src/plugin-host/discovery.ts index 254587b..0ce2b8d 100644 --- a/src/plugin-host/discovery.ts +++ b/src/plugin-host/discovery.ts @@ -195,6 +195,7 @@ function findProfileMenuError(entries: PluginManifest["profileMenu"]): string | for (const entry of Array.isArray(entries) ? entries : []) { const what = `profile-menu entry "${entry?.label ?? "?"}"`; if (typeof entry?.href !== "string" || entry.href === "") return `${what} needs an href`; + if (typeof entry.label !== "string" || entry.label === "") return `${what} needs a label`; const err = gateError(what, entry); if (err) return err; } diff --git a/src/plugin-host/plugin-api.ts b/src/plugin-host/plugin-api.ts index 923b2d4..5d7e824 100644 --- a/src/plugin-host/plugin-api.ts +++ b/src/plugin-host/plugin-api.ts @@ -14,7 +14,7 @@ export type { RequestContext, User } from "../http/context.ts"; export type { PageChrome, ProfileMenuEntry } from "../ui/chrome.ts"; export type { NavNode } from "../ui/nav.ts"; export { can, check, GuardError, requireSession } from "../auth/guards.ts"; -// The three coarse gates a route or nav node may declare — `Route` and `NavNode` both extend it. +// The three coarse gates a route, nav node or profile-menu entry may declare — `Route`, `NavNode` and `ProfileMenuEntry` all extend it. export type { Gate } from "../auth/gate.ts"; // Translation: `ctx.t` and the view-level `t(...)` do the work at runtime — these are for // authoring a plugin's own catalogs (plugins//i18n/.ts) and for building a translator diff --git a/src/plugin-host/plugin.test.ts b/src/plugin-host/plugin.test.ts index 3cecc69..f03ae01 100644 --- a/src/plugin-host/plugin.test.ts +++ b/src/plugin-host/plugin.test.ts @@ -138,7 +138,7 @@ test("findConflicts: a route, nav node or profile entry gating on a permission n { handler: noopHandler, method: "GET", path: "/y", permission: "both:read" }, { handler: noopHandler, method: "POST", path: "/y", permission: "both:read" }, { handler: noopHandler, method: "GET", path: "/z", permission: "declared:write" }, - ], permissions: [], profileMenu: [{ href: "/p", label: "P", permission: "profileonly:read" }] }), + ], permissions: [], profileMenu: [{ href: "/p", label: "P", permission: "profileonly:read" }, { href: "/q", label: "Q", session: true }, { href: "/r", label: "R", public: true }] }), p({ id: "b", nav: [{ id: "b:y", label: "Y", permission: "both:read" }], permissions: [{ name: "declared:write" }] }), ]); assert.ok(gated.every((c) => c.kind === "permission" && c.level === "warn")); diff --git a/src/ui/chrome.test.ts b/src/ui/chrome.test.ts index d64345d..bf61b47 100644 --- a/src/ui/chrome.test.ts +++ b/src/ui/chrome.test.ts @@ -96,18 +96,18 @@ const account: Plugin = { { href: "/account/help", label: "Help", public: true }, ], }; -const entries = (opts: { permissions?: string[]; signedIn?: boolean }): { href: string; label: string }[] => +const entries = (opts: { permissions?: string[]; signedIn?: boolean }): { href: string; icon?: string; label: string }[] => buildPluginChrome({ localeHref: (href) => `${href}?locale=sv-SE`, menu: DEFAULT_MENU, plugins: [account], translatorFor: () => (key) => (key === "account.settings" ? "Inställningar" : key), ...(opts.signedIn === false ? {} : { user: { email: "ada@x.io", id: "u1", permissions: opts.permissions ?? [] } }), - }).user.menu!.map(({ href, label }) => ({ href, label })); + }).user.menu!; test("profile menu entries are gated like nav nodes, translated by their plugin and carry the locale", () => { assert.deepEqual(entries({}), [ - { href: "/account/settings?locale=sv-SE", label: "Inställningar" }, + { href: "/account/settings?locale=sv-SE", icon: "i-user", label: "Inställningar" }, { href: "/account/help?locale=sv-SE", label: "Help" }, ]); assert.deepEqual(entries({ permissions: ["billing:read"] }).map((e) => e.label), ["Inställningar", "account.billing", "Help"]);