Compare commits

..
Author SHA1 Message Date
ajnart 1f65eab0d9 fix: protect demo users from deletion and password changes 2026-09-10 19:52:27 +02:00
Thomas 7c00c18a42 docs(mcp): restore custom widget authoring guidance (#6817) 2026-09-10 19:24:55 +02:00
Thomas ea3dac752f fix(mcp): harden tool schemas and dispatch (#6815) 2026-09-10 19:19:47 +02:00
Thomas f994d665d2 ci: remove automatic test gates and trim test suites (#6816)
* ci: remove automatic test gates and trim test suites

* docs: remove CI audit report
2026-09-10 19:14:31 +02:00
Thomas 3358b6337f fix(mcp): use JSON-compatible invitation dates (#6814) 2026-09-10 17:53:38 +02:00
Thomas 61d3fd5f08 fix(board): restore container grid geometry (#6808) 2026-09-09 15:04:27 +02:00
Thomas 3aac106156 docs: correct v2 beta installation guidance (#6809) 2026-09-09 12:09:14 +02:00
Thomas 9d857377c1 fix: refine release v2 interactions (#6805) 2026-09-08 11:36:19 +02:00
Thomas 923905c529 fix: keep bookmark density consistent (#6804)
* fix: keep bookmark density consistent

* fix: prioritize bookmark titles in compact rows

* fix: preserve compact bookmark text fallback
2026-09-08 10:39:41 +02:00
56 changed files with 771 additions and 1050 deletions

No files matched your search

+10 -57
View File
@@ -97,17 +97,16 @@ jobs:
- name: Validate Custom Widget bundle budget
run: |
pnpm --filter nextjs build
pnpm turbo run build --filter=@homarr/nextjs
pnpm check:custom-widget-bundle
- name: Run unit tests
run: pnpm test
container:
name: Container and E2E
name: Container build
# Same-repository previews already build amd64; only build a fallback when no digest is available.
needs: [fast-gate, preview-amd64]
if: |
!cancelled() && needs.fast-gate.result == 'success' &&
needs.preview-amd64.result != 'failure' && needs.preview-amd64.outputs.digest == '' &&
(
github.event_name == 'merge_group' ||
(github.event_name == 'push' && github.ref == 'refs/heads/release/v2') ||
@@ -115,10 +114,9 @@ jobs:
(github.event_name == 'workflow_dispatch' && (inputs.operation == 'validate' || inputs.operation == 'publish'))
)
runs-on: ubuntu-latest
timeout-minutes: 30
timeout-minutes: 20
permissions:
contents: read
packages: read
steps:
- uses: actions/checkout@v7
if: "!env.ACT"
@@ -126,23 +124,10 @@ jobs:
persist-credentials: false
ref: ${{ github.event_name == 'workflow_dispatch' && inputs.ref || '' }}
- name: Setup
uses: ./tooling/github/setup
- name: Set up Docker Buildx
if: "!env.ACT"
if: needs.preview-amd64.outputs.digest == '' && !env.ACT
uses: docker/setup-buildx-action@v4
- name: Reuse amd64 preview image
if: needs.preview-amd64.outputs.digest != '' && !env.ACT
env:
DIGEST: ${{ needs.preview-amd64.outputs.digest }}
run: |
echo "${{ secrets.GITHUB_TOKEN }}" | docker login ghcr.io -u "${{ github.actor }}" --password-stdin
docker pull "${PREVIEW_IMAGE}@${DIGEST}"
docker tag "${PREVIEW_IMAGE}@${DIGEST}" homarr-ci
docker tag "${PREVIEW_IMAGE}@${DIGEST}" homarr-e2e
- name: Build container once
if: needs.preview-amd64.outputs.digest == '' && !env.ACT
uses: docker/build-push-action@v7
@@ -152,9 +137,7 @@ jobs:
platforms: linux/amd64
load: true
push: false
tags: |
homarr-ci
homarr-e2e
tags: homarr-ci
labels: |
org.opencontainers.image.source=${{ github.server_url }}/${{ github.repository }}
org.opencontainers.image.revision=${{ github.sha }}
@@ -182,37 +165,7 @@ jobs:
fi
done
DOCKER_BUILDKIT=1 docker build --platform linux/amd64 \
--tag homarr-ci --tag homarr-e2e "${secret_args[@]}" .
- name: Cache Playwright browsers
if: "!env.ACT"
id: playwright-cache
uses: actions/cache@v6
with:
path: ~/.cache/ms-playwright
key: playwright-${{ runner.os }}-${{ hashFiles('pnpm-lock.yaml') }}
- name: Install Playwright Chromium
if: steps.playwright-cache.outputs.cache-hit != 'true' || env.ACT
run: pnpm exec playwright install --with-deps chromium
- name: Install Playwright system deps
if: steps.playwright-cache.outputs.cache-hit == 'true'
run: pnpm exec playwright install-deps chromium
- name: Run E2E tests
env:
NODE_ENV: development
CI: "true"
run: pnpm exec vitest e2e --retry 1 --maxWorkers 4
- name: Upload E2E artifacts
if: failure() && !env.ACT
uses: actions/upload-artifact@v7
with:
name: playwright-report
path: e2e/test-results/
retention-days: 7
--tag homarr-ci "${secret_args[@]}" .
preview-amd64:
name: Build amd64 preview image
@@ -320,10 +273,10 @@ jobs:
publish-preview:
name: Publish multi-platform preview image
needs: [container, preview-amd64, preview-arm64]
needs: [fast-gate, preview-amd64, preview-arm64]
if: |
!cancelled() &&
needs.container.result == 'success' &&
needs.fast-gate.result == 'success' &&
needs.preview-amd64.result == 'success' &&
needs.preview-arm64.result == 'success' &&
(
+29 -12
View File
@@ -2,8 +2,36 @@ name: Workshop
on:
pull_request:
paths:
- ".github/workflows/workshop.yml"
- "apps/workshop/**"
- "apps/docs/**"
- "packages/custom-widgets/**"
- "packages/definitions/**"
- "packages/workshop/**"
- "tooling/typescript/**"
- "patches/**"
- "package.json"
- "pnpm-lock.yaml"
- "pnpm-workspace.yaml"
- "turbo.json"
- ".npmrc"
push:
branches: [dev, v2, release/v2, main]
paths:
- ".github/workflows/workshop.yml"
- "apps/workshop/**"
- "apps/docs/**"
- "packages/custom-widgets/**"
- "packages/definitions/**"
- "packages/workshop/**"
- "tooling/typescript/**"
- "patches/**"
- "package.json"
- "pnpm-lock.yaml"
- "pnpm-workspace.yaml"
- "turbo.json"
- ".npmrc"
permissions:
contents: read
@@ -15,21 +43,10 @@ concurrency:
jobs:
validate:
runs-on: ubuntu-24.04
timeout-minutes: 30
timeout-minutes: 5
steps:
- uses: actions/checkout@v7
- uses: actions/setup-node@v6
with:
node-version: 24.18.0
- uses: pnpm/action-setup@v6
with:
run_install: false
- run: pnpm install --frozen-lockfile
- run: pnpm --filter @homarr/workshop typecheck
- run: pnpm --filter @homarr/docs typecheck
- run: docker compose --env-file apps/workshop/.env.staging.example -f apps/workshop/docker-compose.staging.yml config --quiet
- run: pnpm test:workshop
- run: pnpm test:workshop-image
publish-amd64:
if: github.event_name == 'push' && (github.ref_name == 'v2' || github.ref_name == 'release/v2' || github.ref_name == 'main')
Binary file not shown.

Before

Width:  |  Height:  |  Size: 32 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 618 KiB

@@ -23,7 +23,7 @@ Homarr 2.0 is an upgrade, not a brand-new app wearing the same logo. Here is wha
:::warning Back up before upgrading
Follow the [v1 to v2 upgrade guide](/docs/getting-started/upgrade-v2) and create a
Database migrations run automatically when Homarr starts. Create a
[Homarr backup](/docs/management/backup) first. Do not test a prerelease image on your only production volume.
Backups are boring. Restores are worse.
@@ -65,7 +65,7 @@ We have called this the **"ultimate Homarr update"**. Not because work stops, bu
We threw out the old drag-and-drop system and rebuilt it on [`dnd-kit`](https://dndkit.com/). Moves and eight-direction resizes are transactional: Homarr previews the result, checks collisions and only saves a valid layout. Bad moves roll back instead of wrecking the board. In short: less fighting with your own dashboard.
![A Calendar tile repositioned between widgets on the rebuilt Homarr 2.0 board grid](./img/drag-and-drop-still.webp)
![A Calendar tile moving across the rebuilt Homarr 2.0 board grid](./img/drag-and-drop.gif)
### Goodbye Groups and sections, bonjour Containers
@@ -161,7 +161,7 @@ Board startup work runs in parallel, heavy screens load when opened, and identic
## Links
- Try the [Homarr 2.0 preview](https://app-v2.preview.homarr.dev).
- Follow the [v1 to v2 upgrade guide](/docs/getting-started/upgrade-v2).
- Follow the [v2 beta setup guide](/docs/getting-started/upgrade-v2).
- Follow the [v2 release pull request](https://github.com/homarr-labs/homarr/pull/6545) and [public beta thread](https://github.com/homarr-labs/homarr/issues/6600).
- Compare [v1.76.2 with the release/v2 branch](https://github.com/homarr-labs/homarr/compare/v1.76.2...release/v2).
- Read the guides for [Boards](/docs/management/boards), [Custom Widgets](/docs/management/custom-widgets), [Assistant](/docs/management/assistant), [MCP](/docs/management/mcp) and [Docker labels](/docs/integrations/docker-labels).
@@ -82,3 +82,25 @@ Workshop contributors can set up PocketBase and Docusaurus with the dedicated
- `pnpm build` — production monorepo build.
- `docker build -t homarr .` — build a production Docker image.
- `docker run -p 7575:7575 -e SECRET_ENCRYPTION_KEY='your_64_character_hex_string' homarr:<name>` — run it.
## CI and focused validation
The main CI workflow runs lint, typechecking, affected workspace builds, workspace configuration checks, OpenAPI validation, and Custom Widget architecture and bundle checks. Unit and E2E suites do not run in CI. Preview images publish after the Fast gate and both architecture builds succeed; browser tests do not block publication. The container job builds an amd64 image when no preview image was built.
Workshop CI runs only when its workflow or Docker build inputs change. It validates Compose configuration and publishes production images on its configured release branches. Its Docker integration and image tests are manual; workspace typechecks run in the main Fast gate.
Choose the smallest relevant suite when changing behavior:
| Command | Scope | Requirements |
| ------------------------------------------------- | -------------------------------------------------------------- | --------------------------------------------------------------------- |
| `pnpm test <file>` | Focused unit or contract tests | Workspace dependencies |
| `pnpm test` | All unit and contract tests, without Docker integration suites | Workspace dependencies |
| `pnpm test:coverage` | Unit and contract tests with coverage | Workspace dependencies |
| `pnpm test:integration <file>` | Docker-backed service and database compatibility tests | Docker |
| `pnpm test:e2e <file>` | Browser and container scenarios | Docker; Playwright Chromium and system dependencies for browser tests |
| `pnpm test:docs-screenshots` | Regenerate Assistant documentation screenshots | Same browser prerequisites |
| `pnpm test:workshop` / `pnpm test:workshop-image` | Workshop integration / production-image validation | Docker |
Omit `<file>` to run all tests in that command's scope. E2E and screenshot commands use a local image tagged `homarr-e2e`, or an image selected with `HOMARR_E2E_IMAGE`. The default commands run once without automatic retries; coverage is opt-in. The screenshot generator is excluded from both unit and E2E commands.
Keep behavioral coverage for authorization, migrations, persistence, and widget execution. Avoid tests that only search source code for particular component names, JSX, CSS, or documentation wording.
+22 -8
View File
@@ -1,5 +1,5 @@
---
title: Upgrade from v1 to v2
title: Try the v2 beta
sidebar_position: 2
tags:
- Getting started
@@ -8,8 +8,20 @@ tags:
- Backup
---
Homarr v2 upgrades the existing v1 database in place. Keep the same data storage, database configuration, and
`SECRET_ENCRYPTION_KEY`; only change the Homarr image or package version for the first start.
This page covers the temporary Homarr v2 public beta. Try the [live demo](https://app-v2.preview.homarr.dev/)
without installing anything, or run the beta image published in the
[public beta announcement](https://github.com/homarr-labs/homarr/issues/6600):
```sh
docker pull ghcr.io/homarr-labs/homarr-test:v2
```
For a fresh beta installation, follow the [Docker installation guide](/docs/getting-started/installation/docker),
use `ghcr.io/homarr-labs/homarr-test:v2` as the image, and use a separate `/appdata` volume.
Set `WORKSHOP_API_URL=https://v2.preview.homarr.dev` to use the preview Workshop.
Homarr runs database migrations automatically at startup; no manual database migration command is needed.
Once v2 is released, existing installations can use their normal update process. The beta image below is temporary.
:::danger Back up before starting v2
@@ -18,21 +30,23 @@ before starting v2. See [Backup and restore](/docs/management/backup) for detail
:::
## Upgrade
## Try the beta with existing v1 data
1. Pin the Homarr image to `ghcr.io/homarr-labs/homarr:v2.0.0` for the first v2 start.
1. Change the Homarr image to `ghcr.io/homarr-labs/homarr-test:v2`.
2. Keep the existing `/appdata` mount, database variables, Redis configuration, and `SECRET_ENCRYPTION_KEY` unchanged.
3. Start one Homarr replica and follow its logs. Startup must reach `Migration complete`; Homarr aborts startup when a
database migration fails.
4. Open Homarr and sign in. Confirm **Management → About** reports v2 before starting additional replicas.
5. After validation, keep the version pinned or return to your normal stable release policy.
5. Keep using the beta image for beta updates. To return to v1, restore the pre-beta backup as described below.
For Docker Compose, the change is limited to the image tag before the usual update:
Keep your other existing Compose settings, including environment variables. For Docker Compose, update the image and preview Workshop URL before the usual update:
```yaml
services:
homarr:
image: ghcr.io/homarr-labs/homarr:v2.0.0
image: ghcr.io/homarr-labs/homarr-test:v2
environment:
WORKSHOP_API_URL: https://v2.preview.homarr.dev
```
```sh
+6
View File
@@ -27,6 +27,12 @@ session cookie.
API keys do not expire automatically and act as the user that created them. Every request uses that user's group and
resource permissions.
## Invitation dates
`POST /api/invites` and the tRPC `invite.createInvite` procedure accept `expirationDate` as an ISO 8601 timestamp with a
timezone, for example `2026-12-01T18:00:00Z`. Homarr validates the timestamp and stores it as a date. tRPC callers should
send the ISO string rather than a JavaScript `Date` object.
## Health probes
The liveness endpoint does not require authentication:
@@ -36,6 +36,11 @@ current Base layout.
A **Container** groups apps, widgets, and nested Containers. It can have a label, collapse control, app-opening action,
border color, and CSS classes.
Items can occupy the outermost rows and columns of a Container. The drop preview shows the position where the item
will remain when released.
Dragging an item onto another item of the same size swaps their positions. Displaced items snap directly to their
previewed positions without a movement animation and stay there when you release the pointer.
A **rail** reserves one to three columns at the left or right of a non-Mobile layout. Rails stay fixed while the page
scrolls and store their contents separately for each layout.
+37 -34
View File
@@ -8,18 +8,17 @@ tags:
# Model Context Protocol (MCP)
Homarr exposes a stateless [Model Context Protocol](https://modelcontextprotocol.io) server at `/api/mcp`. It maps the
current Homarr API to tools for compatible AI clients.
Connect an AI client to Homarr to manage boards, apps, integrations, and other services through MCP tools.
Homarr uses MCP v2 (protocol `2026-07-28`) at `/api/mcp`, with compatibility for 2025 Streamable HTTP clients.
Requests are stateless: each request authenticates independently, without an MCP session ID.
![The AI / MCP management tab](./img/mcp.png)
The endpoint implements MCP `2026-07-28` and accepts 2025 Streamable HTTP clients through a compatibility path. The old
`/api/mcp/mcp` route remains as an alias; use `/api/mcp` for new clients.
## Connect a client
## Configure a client
Create an API key under **Management → Tools → API → Authentication**, then add the endpoint and header to the client's
MCP configuration:
Create an API key under **Management → Tools → API → Authentication**. Add this configuration to your client,
replacing the URL and API key:
```json
{
@@ -34,7 +33,10 @@ MCP configuration:
}
```
Clients without Streamable HTTP support can use `mcp-remote` over STDIO:
Use the complete key, including the ID, dot, and token. Keep it private and use HTTPS for remote connections.
The **AI / MCP** tab on Homarr's API page provides your endpoint, configuration, and available tools.
For clients that only support STDIO, use `mcp-remote`:
```json
{
@@ -47,26 +49,30 @@ Clients without Streamable HTTP support can use `mcp-remote` over STDIO:
}
```
The **AI / MCP** tab on Homarr's API page shows the current endpoint, configuration, and generated tool catalog. Filter
the catalog by namespace, tool name, or description; matching categories expand automatically.
### OAuth
## Authentication and permissions
Clients with OAuth support can authorize through OAuth 2.1 with PKCE instead of an API key.
Homarr publishes discovery metadata under `/.well-known/`.
MCP supports API keys and OAuth 2.1 with PKCE. API keys are simpler for direct client configuration; OAuth is available
for clients that support dynamic authorization.
If your reverse proxy does not preserve the public host and protocol, set `BASE_URL` to your public origin,
such as `https://homarr.example.com`, without a path.
When Homarr is behind a reverse proxy that does not preserve the public host and protocol, set `BASE_URL` to the exact
public origin, for example `https://homarr.example.com`. Do not include a path. OAuth clients discover the authorization
server and protected resource through Homarr's `/.well-known/` routes.
## Permissions and tools
Tools use the permissions of the API key owner or authorized user. Creating a key does not grant additional access.
Custom Widget and Workshop authoring tools, prompts, and resources are visible only to administrators.
Tools use the permissions of the API key owner or OAuth user. Queries read data; mutations change it.
Inspect the **AI / MCP** tab or your client's tool list for available actions.
Use HTTPS when the endpoint crosses an untrusted network.
Custom Widget and Workshop authoring tools, prompts, and resources require administrator access.
For Custom Widgets, install the official skill and follow the preview-and-evidence workflow described in
[Connect an agent](./custom-widgets/agent-authoring). Secret values are configured in Homarr and are never returned
through MCP.
## Verify the endpoint
The built-in [Assistant](./assistant) uses the same tool catalog with the current Homarr session and asks for approval
before mutations by default.
The 2025 compatibility path accepts a compact `tools/list` request:
## Verify the connection
This request lists tools through the older-client compatibility path. It checks authentication and tool discovery:
```sh
curl -X POST \
@@ -77,17 +83,14 @@ curl -X POST \
https://homarr.example.com/api/mcp
```
The removed HTTP+SSE `/sse` and `/message` transports return `410 Gone`.
A successful discovery or initialization response alone does not verify that tools can be listed.
## Tools and resources
## Troubleshooting
The generated catalog includes available board, app, integration, Docker, Kubernetes, media, DNS, download, and other
Homarr procedures. Queries are read-only; mutations change state. The exact catalog depends on the release and current
user permissions, so inspect `tools/list` or the table in Homarr instead of relying on a static list.
For Custom Widgets, install the official skill and follow the preview-and-evidence workflow described in
[Connect an agent](./custom-widgets/agent-authoring). Secret values are configured in Homarr and are never returned
through MCP.
The built-in [Assistant](./assistant) uses the same tool catalog with the current Homarr session and asks for approval
before mutations by default.
- **`invalid_token`**: copy the complete API key in `<id>.<token>` format.
- **Missing tools**: check the user's permissions. Invalid tool schemas and duplicate names are omitted; check server
logs for `MCP tool omitted from catalog` and report the diagnostic with your Homarr version. Exclude API keys.
- **`Date cannot be represented in JSON Schema`**: update Homarr. Older versions could fail to list all tools because
of an unsupported date schema.
- **`410 Gone` on `/sse` or `/message`**: use `/api/mcp` with a Streamable HTTP client. The old `/api/mcp/mcp` URL remains
an alias.
@@ -18,6 +18,9 @@ password. Decimal units are the default. Change them from the user form or **Com
Preferences → Data size units**. Byte rates follow the same preference; bit rates stay decimal and use labels such as
Mb/s. Deleting a user does not delete boards they created.
In demo mode (`DEMO_MODE=true`), user deletion and password changes are disabled for everyone, including administrators,
even when `DEMO_READ_ONLY=false` allows other changes. This applies to the UI and API.
![The Users management page](../img/users.png)
## Groups
+3 -1
View File
@@ -2572,7 +2572,9 @@
"type": "object",
"properties": {
"expirationDate": {
"type": "string"
"type": "string",
"format": "date-time",
"pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z|([+-](?:[01]\\d|2[0-3]):[0-5]\\d)))$"
}
},
"required": [
@@ -7,6 +7,7 @@ import type { z } from "zod/v4";
import type { RouterOutputs } from "@homarr/api";
import { useUpdateBoard } from "@homarr/boards/updater";
import { revalidatePathActionAsync } from "@homarr/common/client";
import { env } from "@homarr/common/env";
import { useZodForm } from "@homarr/form";
import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
import { useSettings } from "@homarr/settings";
@@ -107,7 +108,7 @@ export const BoardSettingsForm = ({ board, permissions, hasFullAccess, hideVisib
const initialValuesRef = useRef(buildInitialValues(board));
const lastSavedRef = useRef({ pageTitle: board.pageTitle, logoImageUrl: board.logoImageUrl });
useUnsavedChangesGuard(form.isDirty());
useUnsavedChangesGuard(form.isDirty(), { guardBeforeUnload: env.NODE_ENV !== "development" });
useEffect(() => {
return () => {
@@ -1,43 +1,12 @@
import { readFileSync } from "node:fs";
import { describe, expect, it } from "vitest";
import { buildCustomWidgetAiPrompt } from "@homarr/custom-widgets/authoring-prompt";
describe("Copy AI prompt", () => {
it("uses one self-contained prompt without an embedded-skill branch", () => {
const source = readFileSync(
`${process.cwd()}/apps/nextjs/src/app/[locale]/manage/custom-widgets/_copy-ai-prompt-button.tsx`,
"utf8",
);
expect(source).toContain("buildCustomWidgetAiPrompt");
expect(source).not.toContain("embedded-authoring-prompt");
expect(source).not.toContain("Menu");
});
it("copies a complete prompt within the public size limit", () => {
const prompt = buildCustomWidgetAiPrompt(undefined, null, null, "Create a Pokédex");
expect(prompt.length).toBeLessThanOrEqual(12_000);
expect(prompt).toContain("exactly one complete `json` fenced block");
expect(prompt).toContain("Put the complete JSX source directly in the `template` string");
});
it("keeps AI actions and section navigation readable on narrow screens", () => {
const card = readFileSync(
`${process.cwd()}/apps/nextjs/src/app/[locale]/manage/custom-widgets/_custom-widget-ai-card.tsx`,
"utf8",
);
const styles = readFileSync(
`${process.cwd()}/apps/nextjs/src/app/[locale]/manage/custom-widgets/_custom-widget-form.module.css`,
"utf8",
);
const actions = readFileSync(
`${process.cwd()}/apps/nextjs/src/app/[locale]/manage/custom-widgets/_copy-ai-prompt-button.tsx`,
"utf8",
);
expect(card).toContain('<Accordion variant="contained">');
expect(card).not.toContain("defaultValue");
expect(actions).toContain("<ActionIcon");
expect(styles).toContain(".sectionNav > a");
expect(styles).toContain("flex: 0 0 auto");
});
});
@@ -1,36 +0,0 @@
import { readFileSync } from "node:fs";
import { describe, expect, it } from "vitest";
const readSource = (path: string) => readFileSync(`${process.cwd()}/${path}`, "utf8");
describe("legacy custom widget migration UI", () => {
it("keeps legacy definitions visible and exposes the redacted LLM workflow", () => {
const list = readSource("apps/nextjs/src/app/[locale]/manage/custom-widgets/_custom-widget-list.tsx");
const actions = readSource("apps/nextjs/src/app/[locale]/manage/custom-widgets/_custom-widget-actions.tsx");
expect(list).toContain("widget.migrationRequired");
expect(list).toContain('t("page.list.migrationInstructions")');
expect(actions).toContain("legacyMigrationPrompt.fetch");
expect(actions).toContain("navigator.clipboard.writeText(result.prompt)");
expect(actions).toContain("navigator.clipboard.readText()");
expect(actions).toContain('t("action.pasteMigration")');
expect(actions).toContain("parseCustomWidgetClipboardDetailed");
expect(actions).toContain("legacyId={widget.id}");
});
it("replaces the legacy record through migration and refreshes every consumer", () => {
// The import pipeline is shared by the dialog and the Workshop install page.
const importer = readSource("apps/nextjs/src/components/custom-widgets/use-custom-widget-import.ts");
const dialog = readSource("apps/nextjs/src/components/custom-widgets/custom-widget-import-dialog.tsx");
expect(importer).toContain("customWidget.migrateLegacy.useMutation");
expect(importer).toMatch(
/migrateMutation\.mutate\(\{\s*id: legacyId,\s*widget: configuredWidget,\s*secrets,?\s*\}\)/u,
);
expect(importer).toContain("utils.customWidget.list.invalidate()");
expect(importer).toContain("utils.customWidget.available.invalidate()");
expect(importer).toContain("utils.widget.customApi.getData.invalidate()");
expect(dialog).toContain("useCustomWidgetImport({");
expect(dialog).toContain("legacyId,");
});
});
@@ -1,6 +1,5 @@
// @vitest-environment jsdom
import { readFileSync } from "node:fs";
import { act } from "react";
import { createRoot } from "react-dom/client";
import type { Root } from "react-dom/client";
@@ -76,16 +75,6 @@ afterEach(async () => {
});
describe("Custom Widget preview panel", () => {
test("inherits the application theme instead of forcing a reload-sensitive preview theme", () => {
const source = readFileSync(
`${process.cwd()}/apps/nextjs/src/app/[locale]/manage/custom-widgets/_custom-widget-preview-panel.tsx`,
"utf8",
);
expect(source).not.toContain("forceColorScheme");
expect(source).not.toContain("onThemeChange");
});
test("contains invalid definitions inside the widget canvas while keeping every inspection tab", async () => {
await act(async () => {
root.render(
@@ -8,6 +8,7 @@ import { z } from "zod/v4";
import type { RouterOutputs } from "@homarr/api";
import { clientApi } from "@homarr/api/client";
import { revalidatePathActionAsync } from "@homarr/common/client";
import { env } from "@homarr/common/env";
import { colorSchemes } from "@homarr/definitions";
import { useZodForm } from "@homarr/form";
import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
@@ -80,7 +81,7 @@ export const SettingsForm = ({ initialData, selectableBoards, selectableSearchEn
initialValues,
});
useUnsavedChangesGuard(form.isDirty());
useUnsavedChangesGuard(form.isDirty(), { guardBeforeUnload: env.NODE_ENV !== "development" });
const [isSubmitting, setIsSubmitting] = useState(false);
@@ -27,6 +27,7 @@ import type { RouterOutputs } from "@homarr/api";
import { clientApi } from "@homarr/api/client";
import type { BoardPreviewData } from "@homarr/boards/layout-preview";
import { revalidatePathActionAsync } from "@homarr/common/client";
import { env } from "@homarr/common/env";
import { useZodForm } from "@homarr/form";
import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
import { useI18n } from "@homarr/translation/client";
@@ -125,7 +126,7 @@ export const UserGeneralSettingsForm = ({
initialValues,
});
useUnsavedChangesGuard(form.isDirty());
useUnsavedChangesGuard(form.isDirty(), { guardBeforeUnload: env.NODE_ENV !== "development" });
const weekDays = useMemo(() => dayjs.weekdays(false), []);
@@ -45,7 +45,7 @@ export const InviteCreateForm = ({ onClose }: InviteCreateFormProps) => {
onSubmit={form.onSubmit((values) => {
mutate(
{
expirationDate: new Date(values.expirationDate),
expirationDate: new Date(values.expirationDate).toISOString(),
},
{
onSuccess: (result) => {
@@ -7,7 +7,7 @@ import { createUIMessageStream, createUIMessageStreamResponse, jsonSchema, stepC
import { cookies } from "next/headers";
import { z } from "zod/v4";
import { createTRPCContext, mcpRouter } from "@homarr/api/mcp";
import { callMcpTool, createTRPCContext, mcpRouter } from "@homarr/api/mcp";
import {
createAssistantGenerationAccessToken,
getAssistantRequestContextEntitiesAsync,
@@ -547,19 +547,7 @@ export async function POST(request: Request) {
}
if (contextRequestKey !== null) loadedCustomWidgetContextRequests.add(contextRequestKey);
try {
const procedure = mcpTool.pathInRouter.reduce<unknown>(
(current, segment) =>
(typeof current === "object" || typeof current === "function") && current !== null
? (current as Record<string, unknown>)[segment]
: undefined,
caller,
);
if (typeof procedure !== "function") {
throw new Error("Procedure not callable");
}
const result = await (procedure as (value: unknown) => Promise<unknown>)(
executionInput && Object.keys(executionInput as object).length > 0 ? executionInput : undefined,
);
const result = await callMcpTool(caller, mcpTool, executionInput);
customWidgetDiscoveryPhase.observe(mcpTool.name, result);
return toAssistantToolOutput(result, {
maxCharacters: getAssistantToolOutputMaxCharacters(mcpTool.name),
@@ -2,8 +2,11 @@
import { beforeEach, describe, expect, test, vi } from "vitest";
import type * as CatalogModule from "../_extract-tools";
const mocks = vi.hoisted(() => ({
authenticate: vi.fn(),
extractCatalog: vi.fn(),
ipAddress: vi.fn(() => "127.0.0.1"),
toolProcedure: vi.fn(),
loggerWarn: vi.fn(),
@@ -14,7 +17,9 @@ const mocks = vi.hoisted(() => ({
}));
vi.mock("next/server", () => ({ userAgent: () => ({ ua: "MCP route test" }) }));
vi.mock("@homarr/api/mcp", () => ({
vi.mock("@homarr/api/mcp", async () => ({
callMcpTool: (await import("../../../../../../../packages/api/src/mcp-tools")).callMcpTool,
extractMcpToolsFromProcedures: mocks.extractCatalog,
createTRPCContext: vi.fn(() => ({})),
mcpRouter: { createCaller: vi.fn(() => ({ board: { getAllBoards: mocks.toolProcedure } })) },
}));
@@ -54,6 +59,7 @@ vi.mock("../_extract-tools", () => ({
description: "List boards",
type: "query",
pathInRouter: ["board", "getAllBoards"],
inputMode: "none",
inputSchema: { type: "object", properties: {} },
},
{
@@ -61,6 +67,7 @@ vi.mock("../_extract-tools", () => ({
description: "List Custom Widgets",
type: "query",
pathInRouter: ["customWidget", "list"],
inputMode: "none",
inputSchema: { type: "object", properties: {} },
},
{
@@ -68,6 +75,7 @@ vi.mock("../_extract-tools", () => ({
description: "Search Workshop",
type: "query",
pathInRouter: ["customWidget", "workshopSearch"],
inputMode: "none",
inputSchema: { type: "object", properties: {} },
},
],
@@ -305,3 +313,21 @@ describe("MCP protocol errors", () => {
expect(metadata).not.toHaveProperty("message");
});
});
test("logs catalog omissions only when the shared catalog is first built", async () => {
const diagnostic = { name: "broken", path: "broken", reason: "invalid_schema" };
const tools = [{ name: "valid" }];
mocks.extractCatalog.mockReturnValue({ tools, diagnostics: [diagnostic] });
const { extractMcpTools } = await vi.importActual<typeof CatalogModule>("../_extract-tools");
expect(extractMcpTools()).toBe(tools);
expect(extractMcpTools()).toBe(tools);
expect(mocks.extractCatalog).toHaveBeenCalledTimes(1);
expect(mocks.loggerWarn).toHaveBeenCalledExactlyOnceWith("MCP tool omitted from catalog", diagnostic);
});
test("rejects unauthenticated tool calls before execution", async () => {
mocks.authenticate.mockResolvedValue(null);
const { response } = await callMcp("tools/call", { name: "board_getAllBoards", arguments: {} });
expect(response.status).toBe(401);
expect(mocks.toolProcedure).not.toHaveBeenCalled();
});
@@ -1,11 +1,12 @@
import { extractMcpToolsFromProcedures, mcpRouter } from "@homarr/api/mcp";
import { createLogger } from "@homarr/core/infrastructure/logs";
const logger = createLogger({ module: "mcp-catalog" });
let cache: ReturnType<typeof extractMcpToolsFromProcedures> | null = null;
export function extractMcpTools() {
if (cache) return cache;
if (cache) return cache.tools;
cache = extractMcpToolsFromProcedures(mcpRouter);
return cache;
for (const diagnostic of cache.diagnostics) logger.warn("MCP tool omitted from catalog", diagnostic);
return cache.tools;
}
+201 -1
View File
@@ -1,12 +1,18 @@
// @vitest-environment node
import { initTRPC, TRPCError } from "@trpc/server";
import { describe, expect, test, vi } from "vitest";
import { z } from "zod/v4";
import type { McpTool } from "@homarr/api/mcp";
import { appManageSchema } from "@homarr/validation/app";
import { extractMcpToolsFromProcedures } from "@homarr/api/mcp";
import type { McpMeta } from "../../../../../../packages/api/src/mcp-tools";
import { createMcpProtocolHandler } from "./_protocol";
vi.mock("@homarr/api/mcp", async () => import("../../../../../../packages/api/src/mcp-tools"));
const modernMetadata = {
"io.modelcontextprotocol/protocolVersion": "2026-07-28",
"io.modelcontextprotocol/clientInfo": { name: "homarr-test", version: "1.0.0" },
@@ -52,7 +58,7 @@ const echoTool: McpTool = {
description: "Echo a message",
type: "query",
pathInRouter: ["echo"],
inputValidator: echoValidator,
inputMode: "object",
inputSchema: z.toJSONSchema(echoValidator),
};
@@ -163,3 +169,197 @@ describe("MCP v2 protocol", () => {
expect((await parseResponse(listResponse)).result.tools).toEqual([expect.objectContaining({ name: "echo" })]);
});
});
describe.each([false, true])("MCP argument dispatch (modern=%s)", (modern) => {
const trpc = initTRPC.context<{ user: string }>().meta<McpMeta>().create();
const exposed = trpc.procedure.meta({ mcp: { enabled: true } });
const call = async (
handler: ReturnType<typeof createMcpProtocolHandler>,
name: string,
argumentsValue: Record<string, unknown>,
) => {
let request = createRequest("tools/call", { name, arguments: argumentsValue }, name);
if (!modern) {
request = new Request("http://homarr.test/api/mcp", {
method: "POST",
headers: { "Content-Type": "application/json", Accept: "application/json, text/event-stream" },
body: JSON.stringify({
jsonrpc: "2.0",
id: 1,
method: "tools/call",
params: { name, arguments: argumentsValue },
}),
});
}
const response = await handler.fetch(request);
expect(response.headers.has("Mcp-Session-Id")).toBe(false);
return (await parseResponse(response)).result;
};
test("transforms once and does not coerce, insert defaults, or remove properties before tRPC", async () => {
const transform = vi.fn((value: string) => `${value}!`);
const execution = vi.fn((input: unknown) => input);
const router = trpc.router({
update: exposed
.input(
z.looseObject({
name: z.string().transform(transform),
limit: z.number().default(7),
}),
)
.mutation(({ input }) => execution(input)),
});
const catalog = extractMcpToolsFromProcedures(router);
expect(catalog.diagnostics).toEqual([]);
const realCaller = router.createCaller({ user: "one" });
const dispatch = vi.fn(realCaller.update);
const handler = createMcpProtocolHandler({
caller: { update: dispatch },
tools: catalog.tools,
version: "test",
instructions: "test",
formatToolError: () => "Invalid input",
});
const argumentsValue = { name: "hello", extra: "preserved" };
const result = await call(handler, "update", argumentsValue);
expect(result.isError).not.toBe(true);
expect(dispatch).toHaveBeenCalledWith(argumentsValue);
expect(transform).toHaveBeenCalledTimes(1);
expect(execution).toHaveBeenCalledWith({ name: "hello!", limit: 7, extra: "preserved" });
await call(handler, "update", { name: "hello", limit: "7" });
expect(dispatch).toHaveBeenCalledTimes(1);
expect(execution).toHaveBeenCalledTimes(1);
});
test("retains overlapping chained constraints, refinements, and empty object defaults", async () => {
const execution = vi.fn((input: unknown) => input);
const router = trpc.router({
bounded: exposed
.input(z.object({ count: z.number().min(2) }))
.input(z.object({ count: z.number().max(5) }))
.mutation(({ input }) => execution(input)),
refined: exposed
.input(z.object({ value: z.string().refine((value) => value !== "blocked") }))
.mutation(({ input }) => execution(input)),
defaults: exposed.input(z.object({ count: z.number().default(3) })).query(({ input }) => input),
none: exposed.query(({ input }) => ({ absent: input === undefined })),
});
const catalog = extractMcpToolsFromProcedures(router);
expect(catalog.diagnostics).toEqual([]);
const handler = createMcpProtocolHandler({
caller: router.createCaller({ user: "one" }),
tools: catalog.tools,
version: "test",
instructions: "test",
formatToolError: () => "Invalid input",
});
for (const count of [1, 6]) expect((await call(handler, "bounded", { count })).isError).toBe(true);
expect((await call(handler, "refined", { value: "blocked" })).isError).toBe(true);
expect(execution).not.toHaveBeenCalled();
expect((await call(handler, "bounded", { count: 3 })).isError).not.toBe(true);
expect(execution).toHaveBeenCalledWith({ count: 3 });
expect((await call(handler, "defaults", {})).content).toEqual([{ type: "text", text: '{"count":3}' }]);
expect((await call(handler, "none", {})).content).toEqual([{ type: "text", text: '{"absent":true}' }]);
});
test("accepts app inputs that tRPC normalizes before checking constraints", async () => {
const mutation = vi.fn((input: unknown) => input);
const router = trpc.router({ create: exposed.input(appManageSchema).mutation(({ input }) => mutation(input)) });
const { tools, diagnostics } = extractMcpToolsFromProcedures(router);
expect(diagnostics).toEqual([]);
const handler = createMcpProtocolHandler({
caller: router.createCaller({ user: "admin" }),
tools,
version: "test",
instructions: "test",
formatToolError: () => "Invalid input",
});
const argumentsValue = {
name: `${" ".repeat(64)}App `,
description: " description ",
iconUrl: " icon ",
href: null,
pingUrl: " https://example.com ",
};
expect((await call(handler, "create", argumentsValue)).isError).not.toBe(true);
expect(mutation).toHaveBeenCalledExactlyOnceWith({
name: "App",
description: "description",
iconUrl: "icon",
href: null,
pingUrl: "https://example.com",
});
expect((await call(handler, "create", { ...argumentsValue, pingUrl: " javascript:alert(1) " })).isError).toBe(true);
expect(mutation).toHaveBeenCalledTimes(1);
});
test("accepts Zod ISO timestamps without seconds while rejecting invalid dates", async () => {
const mutation = vi.fn((input: unknown) => input);
const router = trpc.router({
invite: exposed
.input(z.object({ expirationDate: z.iso.datetime({ offset: true }) }))
.mutation(({ input }) => mutation(input)),
});
const { tools, diagnostics } = extractMcpToolsFromProcedures(router);
expect(diagnostics).toEqual([]);
const handler = createMcpProtocolHandler({
caller: router.createCaller({ user: "admin" }),
tools,
version: "test",
instructions: "test",
formatToolError: () => "Invalid input",
});
for (const expirationDate of ["2026-12-01T18:00Z", "2026-12-01T19:00+01:00"]) {
expect((await call(handler, "invite", { expirationDate })).isError).not.toBe(true);
expect(mutation).toHaveBeenLastCalledWith({ expirationDate });
}
for (const expirationDate of ["2026-02-30T18:00Z", "not-a-date"]) {
expect((await call(handler, "invite", { expirationDate })).isError).toBe(true);
}
expect(mutation).toHaveBeenCalledTimes(2);
});
test("enforces tRPC permissions before executing mutations", async () => {
const mutation = vi.fn(() => "changed");
const router = trpc.router({
restricted: exposed
.use(({ ctx, next }) => {
if (ctx.user !== "admin") throw new TRPCError({ code: "FORBIDDEN" });
return next();
})
.input(z.object({}))
.mutation(mutation),
});
const { tools, diagnostics } = extractMcpToolsFromProcedures(router);
expect(diagnostics).toEqual([]);
const handler = createMcpProtocolHandler({
caller: router.createCaller({ user: "guest" }),
tools,
version: "test",
instructions: "test",
formatToolError: () => "Forbidden",
});
expect((await call(handler, "restricted", {})).isError).toBe(true);
expect(mutation).not.toHaveBeenCalled();
});
test("isolates authenticated callers when reusing catalog schemas", async () => {
const router = trpc.router({ who: exposed.query(({ ctx }) => ctx.user) });
const { tools } = extractMcpToolsFromProcedures(router);
const handlers = ["alice", "bob"].map((user) =>
createMcpProtocolHandler({
caller: router.createCaller({ user }),
tools,
version: "test",
instructions: "test",
formatToolError: () => "Failed",
}),
);
const results = await Promise.all(handlers.map((handler) => call(handler, "who", {})));
expect(results.map((result) => result.content)).toEqual([
[{ type: "text", text: '"alice"' }],
[{ type: "text", text: '"bob"' }],
]);
});
});
+4 -21
View File
@@ -1,5 +1,6 @@
import { createMcpHandler, McpServer } from "@modelcontextprotocol/server";
import { createMcpHandler, fromJsonSchema, McpServer } from "@modelcontextprotocol/server";
import { callMcpTool } from "@homarr/api/mcp";
import type { McpTool } from "@homarr/api/mcp";
import { toAssistantToolOutput } from "../assistant/chat/assistant-tool-output";
@@ -15,15 +16,6 @@ interface CreateMcpProtocolHandlerOptions {
onProtocolError?: (error: Error) => void;
}
const resolveProcedure = (caller: unknown, path: string[]) =>
path.reduce<unknown>(
(current, segment) =>
(typeof current === "object" || typeof current === "function") && current !== null
? (current as Record<string, unknown>)[segment]
: undefined,
caller,
);
export const createMcpProtocolHandler = ({
caller,
tools,
@@ -52,20 +44,11 @@ export const createMcpProtocolHandler = ({
tool.name,
{
description: tool.description,
inputSchema: tool.inputValidator,
inputSchema: fromJsonSchema(tool.inputSchema as Parameters<typeof fromJsonSchema>[0]),
},
async (args) => {
try {
const procedure = resolveProcedure(caller, tool.pathInRouter);
if (typeof procedure !== "function") {
return {
content: [{ type: "text" as const, text: `Tool "${tool.name}" is not callable` }],
isError: true,
};
}
const input = Object.keys(args).length > 0 ? args : undefined;
const result = await (procedure as (value: unknown) => Promise<unknown>)(input);
const result = await callMcpTool(caller, tool, args);
return {
content: [
{
@@ -1,9 +1,11 @@
.board-grid-editor {
position: absolute;
inset: 0 auto auto 0;
inset: 0 0 auto;
margin: 0 auto;
overflow: visible;
border-radius: var(--mantine-radius-md);
pointer-events: none;
zoom: var(--board-grid-content-scale, 1);
}
.board-grid-entry {
@@ -20,14 +22,7 @@
width: var(--board-grid-preview-width) !important;
height: var(--board-grid-preview-height) !important;
translate: var(--board-grid-preview-x) var(--board-grid-preview-y);
will-change: translate;
}
.board-grid-entry[data-grid-preview="true"]:not([data-dnd-active="true"]) {
transition:
translate 100ms ease,
width 100ms ease,
height 100ms ease;
transition: none;
}
.board-grid-entry[data-dnd-active="true"],
@@ -109,8 +104,8 @@
zoom: var(--board-grid-overlay-content-zoom, 1);
}
body[data-board-grid-interacting] .board-grid-entry[data-dnd-active="true"],
body[data-board-grid-interacting] .board-grid-entry[data-dnd-drag-source="true"],
/* Displaced entries must snap with the preview too; animating their offsets can trail a new swap target. */
body[data-board-grid-interacting] .board-grid-entry,
body[data-board-grid-interacting] .board-grid-placeholder {
transition: none;
}
@@ -415,7 +415,11 @@ export const BoardGridEditorProvider = ({ children }: PropsWithChildren) => {
sectionId: grid.id,
placements: grid.placements,
}));
commitSectionGrids(snapshots);
const hasChanges = commitSectionGrids(snapshots);
if (!hasChanges) {
clearInteraction();
return;
}
const placement = state.grids
.flatMap((grid) => grid.placements)
@@ -429,7 +433,9 @@ export const BoardGridEditorProvider = ({ children }: PropsWithChildren) => {
)} ${placement.h}`,
);
}
clearInteraction();
// Cache subscribers receive the committed board asynchronously. Keep the preview until
// the board-change layout effect clears it, so displaced items never flash at their old positions.
activeRef.current = null;
},
[announce, clearInteraction, commitSectionGrids, currentLayoutId, t],
);
@@ -46,7 +46,6 @@
.editorGrid {
position: absolute;
inset: 0 auto auto 0;
}
.editorPortalHost {
@@ -63,6 +62,11 @@
overflow: visible;
}
/* Containers provide their own inner item gap, so their card must keep the full grid aspect ratio. */
.staticItem[data-type="section"] > .contentMount {
inset: 0;
}
.staticItem[data-type="item"] > .contentMount {
inset: calc(10px * var(--board-canvas-inverse-scale, 1));
}
@@ -143,40 +143,15 @@ export const SectionGrid = ({
const isScrollableContainer = section.kind === "container" && section.options.scrollable;
const viewportRowCount =
viewportRowCountOverride ?? (isScrollableContainer ? Math.max(requestedRowCount, 1) : rowCount);
// A container's own visible card is inset from its allocated board cell by the board's
// standard per-item gap (see the base, non-container `.staticItem[data-type="item"] >
// .contentMount` rule in section-grid.module.css - a container is placed as an "item" like
// any widget, so it gets that same gap). A widget's content just fills whatever size its
// card ends up being, but this SectionGrid instead computes its own fixed pixel size from
// the same column/row counts used to allocate the *outer*, uninset cell - so without
// subtracting that gap back out here, a container's inner grid renders larger than its own
// card and visually spills past its right/bottom edges. 10 must match that CSS rule's inset.
const effectiveCanvasScale = Number.isFinite(canvasScale) && canvasScale > 0 ? canvasScale : 1;
const outerCardInset = section.kind === "container" ? (2 * 10) / effectiveCanvasScale : 0;
// A collapsible container's toggle bar (see the `containerToggle` Button in
// container-section.tsx) is an absolutely positioned overlay sitting on top of this grid.
// Its height comes from a Mantine size prop, which - like spacing/font-size - is compensated
// by --mantine-scale to render at a constant physical size regardless of board zoom (see
// scaled-board-canvas.module.css). That makes it behave like the outerCardInset gap above,
// not like the grid's own un-compensated logical-pixel math, so it needs the same
// effectiveCanvasScale conversion - a plain, unconverted subtraction would under-reserve on
// a zoomed-out board and leave the header overlapping the content. Without reserving it at
// all, the header bar covers the first row of the container's content instead of sitting
// above it. Only the vertical axis is affected - the toggle spans the full width already.
// The collapsible label keeps a fixed physical height while the grid uses logical canvas pixels.
// Convert that height so the label reserves the same space at every board scale.
const collapsibleHeaderInset =
section.kind === "container" && section.options.collapsible ? CONTAINER_HEADER_HEIGHT / effectiveCanvasScale : 0;
const logicalWidth = getLogicalGridSize(columnCount) - outerCardInset;
const viewportHeight = Math.max(1, getLogicalGridSize(viewportRowCount) - outerCardInset - collapsibleHeaderInset);
// Items are positioned in a coordinate space sized to the *un-inset* column/row count
// (fullGridWidth/Height below - see getLogicalItemStyle), but logicalWidth/Height above are
// deliberately smaller by outerCardInset to match the container's actual visible card size.
// Left alone, that mismatch means the items - not just the grid's own box - spill past the
// card's right/bottom edge by exactly that inset. Scale the grid's content down by the same
// ratio the board's own canvas uses for its whole-board zoom (see ScaledBoardCanvas), just one
// level deeper, so it fits the actual card instead of clipping. --board-canvas-ui-scale is
// corrected to compensate so icon/text sizing inside the container isn't affected.
const fullGridWidth = getLogicalGridSize(columnCount);
const fullGridHeight = getLogicalGridSize(rowCount);
const logicalWidth = fullGridWidth;
const viewportHeight = Math.max(1, getLogicalGridSize(viewportRowCount) - collapsibleHeaderInset);
// For a scrollable container, rowCount covers *all* content rows, not just the visible card -
// fullGridHeight grows right along with it, so a ratio built from it drifts toward 1 as content
// grows regardless of the (fixed) inset, under-scaling relative to what the actually-visible
@@ -184,16 +159,9 @@ export const SectionGrid = ({
// describe the real visible card size in both cases (they equal the non-scrollable values when
// rowCount and viewportRowCount are the same), so use those instead.
const fullViewportHeight = getLogicalGridSize(viewportRowCount);
// A uniform zoom is required (not a non-uniform transform scale(x, y)) - --board-canvas-ui-scale
// is a single scalar that every icon/text/custom-CSS size compensation in the app multiplies
// by, so a non-uniform stretch can never be captured by it correctly. That means one axis can
// be left with a small amount of unfilled slack when width/height need different ratios to
// exactly fit - centered below so it's even on both sides rather than left-aligned.
// logicalWidth/viewportHeight can go non-positive for a narrow (e.g. single-column) container
// on a heavily zoomed-out board, where outerCardInset (which grows as canvasScale shrinks) can
// exceed the container's own un-inset size. Floor the scale well above 0 so it can never reach
// zero or negative - combinedUiScale divides by this value, and this value is also used as
// zoom directly, both of which break (Infinity/NaN, or invalid/collapsed content) otherwise.
// A collapsible container reserves a fixed-height label above the square-cell grid. Keep the
// nested content uniformly scaled within the remaining height so labels and icons retain their
// proportions; ordinary containers use the original unscaled, edge-aligned grid geometry.
const containerContentScale =
section.kind === "container" && fullGridWidth > 0 && fullViewportHeight > 0
? Math.max(0.01, Math.min(logicalWidth / fullGridWidth, viewportHeight / fullViewportHeight, 1))
@@ -302,6 +270,7 @@ export const SectionGrid = ({
height: `var(--board-grid-drag-height, ${viewportHeight}px)`,
marginTop: collapsibleHeaderInset || undefined,
"--board-item-radius": `var(--mantine-radius-${board.itemRadius})`,
"--board-grid-content-scale": containerContentScale,
} as CSSProperties
}
data-section-id={section.id}
@@ -33,8 +33,8 @@ export const useGridLayoutActions = () => {
placements.map((placement) => [`${layoutId}:${placement.id}`, { layoutId, sectionId, placement }] as const),
),
);
let hasChanges = false;
updateBoard((previous) => {
let hasChanges = false;
const items = previous.items.map((item) => {
const layouts = item.layouts.map((layout) => {
const resolved = byId.get(`${layout.layoutId}:${item.id}`);
@@ -97,6 +97,7 @@ export const useGridLayoutActions = () => {
return hasChanges ? { ...previous, items, sections } : previous;
});
return hasChanges;
},
[updateBoard],
);
@@ -21,6 +21,9 @@
--border-color: rgb(
from var(--container-border-color, var(--container-default-border-color)) r g b / var(--opacity, 1)
);
/* Keep the border visual without letting it shrink the square-cell grid's content box. */
border-width: 0;
box-shadow: inset 0 0 0 calc(1px * var(--board-canvas-inverse-scale, 1)) var(--border-color);
}
.itemCard:global(.iframe-wrapper) {
@@ -1,9 +1,14 @@
interface UnsavedChangesGuardOptions {
isDirty(): boolean;
confirmNavigation(href: string): void;
guardBeforeUnload?: boolean;
}
export function registerUnsavedChangesGuard({ isDirty, confirmNavigation }: UnsavedChangesGuardOptions) {
export function registerUnsavedChangesGuard({
isDirty,
confirmNavigation,
guardBeforeUnload = true,
}: UnsavedChangesGuardOptions) {
let currentHref = `${window.location.pathname}${window.location.search}${window.location.hash}`;
const handleClick = (event: MouseEvent) => {
if (!isDirty() || event.defaultPrevented || event.button !== 0) return;
@@ -17,6 +22,7 @@ export function registerUnsavedChangesGuard({ isDirty, confirmNavigation }: Unsa
const current = new URL(window.location.href);
if (destination.origin !== current.origin) return;
if (destination.pathname === current.pathname && destination.search === current.search) {
currentHref = `${destination.pathname}${destination.search}${destination.hash}`;
return;
}
@@ -48,11 +54,11 @@ export function registerUnsavedChangesGuard({ isDirty, confirmNavigation }: Unsa
};
document.addEventListener("click", handleClick, true);
window.addEventListener("beforeunload", handleBeforeUnload);
if (guardBeforeUnload) window.addEventListener("beforeunload", handleBeforeUnload);
window.addEventListener("popstate", handlePopState);
return () => {
document.removeEventListener("click", handleClick, true);
window.removeEventListener("beforeunload", handleBeforeUnload);
if (guardBeforeUnload) window.removeEventListener("beforeunload", handleBeforeUnload);
window.removeEventListener("popstate", handlePopState);
};
}
@@ -7,7 +7,11 @@ import { useConfirmModal } from "@homarr/modals";
import { useI18n } from "@homarr/translation/client";
import { registerUnsavedChangesGuard } from "./unsaved-changes-guard";
export function useUnsavedChangesGuard(isDirty: boolean) {
interface UnsavedChangesGuardOptions {
guardBeforeUnload?: boolean;
}
export function useUnsavedChangesGuard(isDirty: boolean, options: UnsavedChangesGuardOptions = {}) {
const tConfirmLeave = useI18n("board.action.edit.confirmLeave");
const tCommon = useI18n("common");
const router = useRouter();
@@ -39,7 +43,8 @@ export function useUnsavedChangesGuard(isDirty: boolean) {
registerUnsavedChangesGuard({
isDirty: () => dirtyRef.current,
confirmNavigation: (href) => confirmNavigationRef.current(href),
guardBeforeUnload: options.guardBeforeUnload,
}),
[],
[options.guardBeforeUnload],
);
}
@@ -1,18 +0,0 @@
import { readFileSync } from "node:fs";
import { describe, expect, it } from "vitest";
describe("DynamicBreadcrumb", () => {
it("falls back to the raw segment when a path segment has no translation", () => {
const source = readFileSync(
`${process.cwd()}/apps/nextjs/src/components/navigation/dynamic-breadcrumb.tsx`,
"utf8",
);
// Dynamic ids (e.g. /manage/custom-widgets/workshop/<id>) have no message and are
// not mapped while their page loads; resolving them must not raise MISSING_MESSAGE.
expect(source).toContain("let label = pathnamePart");
expect(source).toContain("if (mappedValue !== undefined)");
expect(source).toContain("t.has(labelKey)");
expect(source).not.toMatch(/\{t\(`\$\{translationKey\}\.label`/u);
});
});
@@ -1,18 +1,9 @@
// @vitest-environment jsdom
import { readFileSync } from "node:fs";
import { afterEach, describe, expect, it } from "vitest";
import { getWorkshopAssistantProviderUrl, getWorkshopWebUrl } from "./workshop-client";
const read = (path: string) => readFileSync(`${process.cwd()}/${path}`, "utf8");
const detailPage = "apps/nextjs/src/app/[locale]/manage/custom-widgets/workshop/[id]/_workshop-detail.tsx";
const browsePage = "apps/nextjs/src/app/[locale]/manage/custom-widgets/workshop/_workshop-browse.tsx";
const browseRoutePage = "apps/nextjs/src/app/[locale]/manage/custom-widgets/workshop/page.tsx";
const publishPage = "apps/nextjs/src/app/[locale]/manage/custom-widgets/publish/[id]/_workshop-publish-form.tsx";
const cssButton = "apps/nextjs/src/components/workshop/workshop-css-import-button.tsx";
describe("Workshop widget install flow", () => {
afterEach(() => {
document.head.innerHTML = "";
@@ -39,136 +30,4 @@ describe("Workshop widget install flow", () => {
expect(getWorkshopAssistantProviderUrl()).toBe("https://api.preview.example/api/ai/v1");
});
it("installs from a page with a sticky footer instead of stacked modals", () => {
const detail = read(detailPage);
expect(detail).toContain("ManageStickyFooter");
expect(detail).toContain("useCustomWidgetImport");
// Review and install happen on one surface, so neither Modal.Stack nor the review dialog is involved.
expect(detail).not.toContain("Modal.Stack");
expect(detail).not.toContain("useModalsStack");
expect(detail).not.toContain("CustomWidgetImportDialog");
});
it("browses the Workshop as a card grid with per-item Workshop links", () => {
const browse = read(browsePage);
const route = read(browseRoutePage);
expect(browse).toContain("WorkshopSubmissionGrid");
expect(browse).toContain("SearchInput");
expect(browse).toContain("TablePagination");
expect(browse).toContain("CustomWidgetTabs");
expect(route).toContain("resolveHomarrUrlConfig");
expect(route).toContain("workshopWebUrl={workshopWebUrl}");
expect(browse).toContain("workshopWebUrl: string");
expect(browse).toContain("href={workshopWebUrl}");
expect(browse).toContain('workshopWebUrl.replace(/\\/+$/u, "")');
expect(browse).toContain("encodeURIComponent(item.id)");
expect(browse).toContain("WorkshopVoteControl");
expect(browse).not.toContain("Modal");
});
it("renders the grid with real cards and screenshot previews", () => {
const grid = read("apps/nextjs/src/components/workshop/workshop-submission-grid.tsx");
const card = read("apps/nextjs/src/components/workshop/workshop-submission-card.tsx");
expect(grid).toContain("SimpleGrid");
expect(card).toContain("Card.Section");
expect(card).toContain("PREVIEW_HEIGHT");
});
it("shows the viewer's own vote as a filled control", () => {
const vote = read("apps/nextjs/src/components/workshop/workshop-vote-control.tsx");
expect(vote).toContain("useWorkshopUserVotesQuery");
expect(vote).toContain("IconArrowBigUpFilled");
expect(vote).toContain("IconArrowBigDownFilled");
expect(vote).toContain('color={upvoted ? "red" : "gray"}');
expect(vote).toContain("aria-pressed={upvoted}");
});
it("previews screenshots large with a zoom lightbox", () => {
const screenshots = read("apps/nextjs/src/components/workshop/workshop-screenshots.tsx");
const detail = read(detailPage);
expect(screenshots).toContain("1200x800");
expect(screenshots).toContain("zoomScreenshot");
expect(screenshots).toContain('import { Lightbox } from "@mantine/lightbox"');
expect(screenshots).toContain("withThumbnails");
expect(screenshots).toContain("withZoom");
expect(screenshots).toContain("withKeyboardEvents");
expect(screenshots).not.toMatch(/<Modal\b/u);
expect(detail).toContain("WorkshopScreenshots");
});
it("publishes from a page with the same sticky footer", () => {
const publish = read(publishPage);
expect(publish).toContain("ManageStickyFooter");
expect(publish).not.toContain("useModalsStack");
expect(publish).not.toMatch(/<Modal\b/u);
});
it("imports Custom CSS from a single modal", () => {
const css = read(cssButton);
expect(css).toContain("WorkshopSubmissionGrid");
expect(css).toContain("useConfirmModal");
expect(css).not.toContain("useModalsStack");
expect(css).not.toContain("Modal.Stack");
});
it("cannot import the same widget twice and returns to the installed list", () => {
const importer = read("apps/nextjs/src/components/custom-widgets/use-custom-widget-import.ts");
const detail = read(detailPage);
// The mutation creates a new record per call, so a second click must be impossible.
expect(importer).toContain("if (!configuredWidget || pending || succeeded) return;");
expect(importer).toContain("setSucceeded(true)");
expect(detail).toContain("router.push(installedHref)");
expect(detail).toContain("disabled={!compatible || !importer.ready || importer.succeeded}");
// The heavy edit form is no longer the post-install destination.
expect(detail).not.toContain("/manage/custom-widgets/edit/");
});
it("keeps typed credentials across a background refetch of the submission", () => {
const importer = read("apps/nextjs/src/components/custom-widgets/use-custom-widget-import.ts");
const detail = read(detailPage);
// Voting invalidates the detail query. If the setup reset keyed off the widget
// object identity, the refetch would silently clear part-typed API keys.
expect(importer).toContain("const sourcesKey = JSON.stringify(widget?.sources ?? {})");
expect(importer).toContain("[sourcesKey]");
expect(importer).not.toContain("getCustomWidgetSourceSetups(widget?.sources ?? {}), [widget])");
expect(detail).toContain("const content = detail.data?.content;");
expect(detail).toContain("[content]");
});
it("keeps the sign-in hint reachable when voting is unavailable", () => {
const vote = read("apps/nextjs/src/components/workshop/workshop-vote-control.tsx");
// Mantine tooltips do not fire on disabled controls, so the hint hangs off the wrapper.
expect(vote).toContain('canVote ? controls : <Tooltip label={t("signInHint")}>{controls}</Tooltip>');
});
it("cannot publish the same widget twice", () => {
const publish = read(publishPage);
expect(publish).toContain("if (createSubmission.isPending) return;");
expect(publish).toContain("createSubmission.isPending ||");
});
it("no longer ships the stacked-modal install components", () => {
for (const removed of [
"apps/nextjs/src/components/workshop/workshop-browser.tsx",
"apps/nextjs/src/components/workshop/workshop-install-button.tsx",
"apps/nextjs/src/components/workshop/workshop-installer.tsx",
"apps/nextjs/src/components/workshop/workshop-publish-modal.tsx",
"apps/nextjs/src/components/workshop/workshop-submission-list.tsx",
"apps/nextjs/src/components/workshop/workshop-submission-item.tsx",
]) {
expect(() => read(removed)).toThrow();
}
});
});
+1 -1
View File
@@ -77,7 +77,7 @@ pnpm --filter @homarr/docs typecheck
```
The integration suite uses a disposable Compose project. `pnpm test:workshop-image` additionally builds and smoke-tests
the combined production image.
the combined production image. Both commands are manual and do not run in CI. Workshop CI validates Compose configuration only when its workflow or Docker build inputs change, then publishes images on release branches. The main Fast gate owns workspace typechecking.
## Data safety
+3 -113
View File
@@ -1,48 +1,7 @@
import { access, readFile } from "node:fs/promises";
import { resolve } from "node:path";
import { readFile } from "node:fs/promises";
import vm from "node:vm";
const read = (path) => readFile(resolve(path), "utf8");
const packageJson = JSON.parse(await read("packages/workshop/package.json"));
const clientExport = packageJson.exports?.["./client"];
if (typeof clientExport !== "string") throw new Error("Workshop client export is missing");
await access(resolve("packages/workshop", clientExport));
const hook = await read("apps/workshop/pb_hooks/workshop.pb.js");
if (!hook.includes("require(`${__hooks}/workshop-utils.js`)")) {
throw new Error("Workshop handlers must load shared helpers inside their isolated PocketBase contexts");
}
if (!hook.includes("onBootstrap") || !hook.includes("users.oauth2.providers = configured")) {
throw new Error("Workshop OAuth settings must be synchronized at every bootstrap");
}
if (!hook.includes('username: "name"') || hook.includes("onRecordAuthWithOAuth2Request")) {
throw new Error("Workshop identity must use PocketBase's direct GitHub username mapping");
}
if (hook.includes("validateAndNormalizeSubmission")) {
throw new Error("PocketBase must store Workshop submissions without interpreting their content");
}
for (const artifact of [
"apps/workshop/pb_hooks/widget-validator.js",
"apps/workshop/pb_hooks/widget-validator.bundle.cjs",
"packages/workshop/src/pocketbase-validator.ts",
"scripts/build-workshop-validator.mjs",
]) {
await access(resolve(artifact))
.then(() => {
throw new Error(`Workshop validator artifact must stay removed: ${artifact}`);
})
.catch((error) => {
if (error instanceof Error && "code" in error && error.code === "ENOENT") return;
throw error;
});
}
if (hook.includes('findAllRecords("users")') || !hook.includes('findRecordsByFilter("users"')) {
throw new Error("Workshop report notifications must use a filtered, bounded administrator query");
}
if (!hook.includes('routerAdd("GET", "/workshop/{id}"') || !hook.includes('getStringSlice("screenshots")')) {
throw new Error("Workshop detail routes must render server-side social metadata with submission screenshots");
}
const read = (path) => readFile(path, "utf8");
const hookUtilsModule = { exports: {} };
vm.runInNewContext(await read("apps/workshop/pb_hooks/workshop-utils.js"), { module: hookUtilsModule });
@@ -69,73 +28,4 @@ for (const expected of [
}
if (socialHtml.includes("Homarr documentation")) throw new Error("Workshop social metadata must replace generic tags");
const migration = await read("apps/workshop/pb_migrations/1784240000_workshop_widgets.js");
for (const removedField of ["displayName", "avatarUrl", "githubProfileUrl", "githubUsername"]) {
if (migration.includes(removedField)) throw new Error(`Redundant Workshop user field remains: ${removedField}`);
}
const providerMigration = await read("apps/workshop/pb_migrations/1786500000_homarr_provider.js");
for (const required of ['name: "assistant_quotas"', 'name: "assistant_global_quota"', "idx_assistant_quotas_user"]) {
if (!providerMigration.includes(required)) throw new Error(`Homarr provider migration is missing: ${required}`);
}
for (const forbidden of ['name: "assistant_requests"', 'name: "assistant_activity"', 'name: "dailyLimit"']) {
if (providerMigration.includes(forbidden)) throw new Error(`Homarr provider must not persist ${forbidden}`);
}
const dailyQuotaMigration = await read("apps/workshop/pb_migrations/1787670446_daily_assistant_quotas.js");
for (const required of [
'removeIndex("idx_assistant_quotas_user")',
'addIndex("idx_assistant_quotas_user_day", true, "user, day", "")',
]) {
if (!dailyQuotaMigration.includes(required)) throw new Error(`Daily quota migration is missing: ${required}`);
}
const providerBackend = await read("apps/workshop/homarr_provider.go");
for (const required of [
'"homarr/model"',
'apis.RequireAuth("users")',
"DefaultActivityLoggerMiddlewareId",
'os.Getenv("OPENROUTER_API_KEY")',
'os.Getenv("HOMARR_AI_DAILY_REQUEST_LIMIT")',
'os.Getenv("HOMARR_AI_GLOBAL_DAILY_REQUEST_LIMIT")',
'os.Getenv("HOMARR_AI_OPENROUTER_MODEL")',
'"data_collection": "deny"',
'"zdr": true',
]) {
if (!providerBackend.includes(required)) throw new Error(`Homarr provider backend is missing: ${required}`);
}
if (/logger\(\)|\.Logger\(|log\.Print/u.test(providerBackend)) {
throw new Error("The Homarr provider must not log request content or user tokens");
}
for (const required of [
"workshop_migration_state",
"addedUserFields",
"cloneRule(users.listRule)",
"CREATE TRIGGER submissions_revision_cas",
"NEW.expectedRevision != OLD.revision",
"users.passwordAuth = state.users.passwordAuth",
"users.oauth2 = state.users.oauth2",
"app.delete(users)",
]) {
if (!migration.includes(required)) throw new Error(`Workshop rollback is missing state restoration: ${required}`);
}
const workflow = await read(".github/workflows/workshop.yml");
if (/\n\s+paths:/u.test(workflow)) throw new Error("Workshop workflow must not filter out Docker build inputs");
const entrypoint = await read("apps/workshop/entrypoint.sh");
if (!entrypoint.includes("/pb_public/workshop-runtime-config.js") || !entrypoint.includes("WORKSHOP_API_URL")) {
throw new Error("Workshop must publish its API URL when the container starts");
}
const docsConfig = await read("apps/docs/docusaurus.config.ts");
if (!docsConfig.includes('scripts: [{ src: "/workshop-runtime-config.js" }]')) {
throw new Error("Workshop must load the container runtime configuration before the documentation bundle");
}
if (!docsConfig.includes('path: "/workshop/:id"') || !docsConfig.includes("priority: -1")) {
throw new Error("Workshop submission routes must not capture the moderation page");
}
await access(resolve("apps/docs/static/workshop-runtime-config.js"));
await access(resolve("apps/docs/src/pages/workshop/admin/index.tsx"));
console.log("Workshop static contracts passed");
console.log("Workshop social metadata rendering passed");
+1 -2
View File
@@ -16,8 +16,7 @@ const adminCredentials = {
password: "Comp(exP4sswOrd",
};
// This is an opt-in documentation asset generator, not part of the normal E2E gate.
// Run it explicitly with UPDATE_DOCS_SCREENSHOTS=true when the documented UI changes.
// Run this opt-in asset generator with pnpm test:docs-screenshots when the documented UI changes.
describe.skipIf(process.env.UPDATE_DOCS_SCREENSHOTS !== "true")("Assistant documentation screenshots", () => {
test("captures the management page and conversation panel", async () => {
const { db, localMountPath } = await createSqliteDbFileAsync();
-114
View File
@@ -1,114 +0,0 @@
import { chromium, expect } from "@playwright/test";
import type { Browser } from "@playwright/test";
import type { StartedTestContainer } from "testcontainers";
import { describe, test } from "vitest";
import { createHomarrContainer } from "./shared/create-homarr-container";
import { createSqliteDbFileAsync } from "./shared/e2e-db";
import type { MockApiServer } from "./shared/mock-api-server";
import { startMockApiServerAsync } from "./shared/mock-api-server";
import { seedAdminUserAsync } from "./shared/seed-admin-user";
const adminCredentials = { username: "admin", password: "Comp(exP4sswOrd" };
const initialTemplate =
"<Stack><Title order={4}>{data.status?.title}</Title><Badge>{data.status?.status}</Badge></Stack>";
const updatedTemplate = "<Stack><Text fw={700}>{data.status?.title}</Text><Text>{data.status?.value}</Text></Stack>";
describe("Custom JSX v2 workbench", () => {
test.skip("creates, previews, edits, and immediately adds a widget to a board", async () => {
let mockApi: MockApiServer | undefined;
let homarrContainer: StartedTestContainer | undefined;
let browser: Browser | undefined;
try {
mockApi = await startMockApiServerAsync({ title: "E2E Widget", status: "online", value: 42 });
const { db, localMountPath } = await createSqliteDbFileAsync();
await seedAdminUserAsync(db, adminCredentials);
homarrContainer = await createHomarrContainer({
environment: { AUTH_PROVIDERS: "credentials" },
mounts: { "/appdata": localMountPath },
}).start();
const baseUrl = `http://${homarrContainer.getHost()}:${homarrContainer.getMappedPort(7575)}`;
browser = await chromium.launch();
const page = await browser.newPage();
await page.goto(`${baseUrl}/auth/login`);
await page.waitForLoadState("networkidle");
await page.getByLabel("Username").fill(adminCredentials.username);
await page.locator("#password").fill(adminCredentials.password);
const signedIn = page.waitForURL((url) => url.origin === baseUrl && url.pathname !== "/auth/login", {
waitUntil: "commit",
timeout: 60_000,
});
await page.locator("button[type='submit']").click();
await signedIn;
await page.goto(`${baseUrl}/manage/custom-widgets/new`);
await page.locator('input[data-path="name"]:visible').fill("E2E Custom JSX v2");
await page.getByRole("textbox", { name: "Base URL" }).fill(mockApi.url);
await page.getByRole("combobox", { name: "Network access" }).click();
await page.getByRole("option", { name: "private" }).click();
await page.getByRole("group", { name: "status" }).getByRole("textbox", { name: "Path" }).fill("/status");
await page.getByRole("button", { name: "Add option" }).click();
const option = page.getByRole("group", { name: "Option 1" });
await option.getByRole("textbox", { name: "Option name" }).fill("label");
await option.getByRole("textbox", { name: "Default" }).fill("E2E");
const request = page.getByRole("group", { name: "status" });
await request.getByRole("button", { name: "Query, body and behavior" }).click();
await request.getByRole("button", { name: "Add query value" }).click();
await request.getByRole("textbox", { name: "Query key" }).fill("label");
await request.getByRole("combobox", { name: "Value source" }).click();
await page.getByRole("option", { name: "Widget option" }).click();
await request.getByRole("combobox", { name: "Option" }).first().click();
await page.getByRole("option", { name: "label" }).click();
await page.getByRole("button", { name: "Advanced manifest JSON" }).first().click();
await expect(page.locator("#requests-editor")).toContainText('"$option": "label"');
await page.locator("#jsx-editor").fill(initialTemplate);
await page.getByRole("button", { name: "Test and preview" }).last().click();
await expect(page.getByRole("complementary", { name: "Widget preview" }).getByText("E2E Widget")).toBeVisible({
timeout: 15_000,
});
const editPage = page.waitForURL("**/manage/custom-widgets/edit/**", { timeout: 15_000, waitUntil: "commit" });
await page.getByRole("button", { name: "Create", exact: true }).last().click();
await editPage;
await page.locator("#jsx-editor").fill(updatedTemplate);
await page.getByRole("button", { name: "Save", exact: true }).last().click();
await expect(page.getByText('Widget "E2E Custom JSX v2" updated successfully.')).toBeVisible({ timeout: 15_000 });
await page.goto(baseUrl);
const tourOverlay = page.locator('[data-onboarding-tour-overlay="true"]');
const skipTour = page.getByRole("button", { name: "Skip tour", exact: true });
try {
await skipTour.waitFor({ state: "visible", timeout: 5_000 });
await skipTour.click();
await expect(tourOverlay).toHaveCount(0);
} catch (error) {
if ((await tourOverlay.count()) > 0) throw error;
}
const editToggle = page.getByTestId("board-edit-mode-toggle");
await editToggle.click();
await expect(editToggle).toHaveAttribute("aria-pressed", "true", { timeout: 15_000 });
await page.getByRole("button", { name: "Add board content", exact: true }).click();
await page
.getByRole("dialog", { name: "Create" })
.getByRole("button", { name: /^Add widget / })
.click();
const picker = page.getByRole("dialog").last();
await picker.getByRole("button", { name: /^E2E Custom JSX v2,/ }).click();
const addDialog = page.getByRole("dialog").last();
await expect(addDialog.getByText("E2E Custom JSX v2")).toBeVisible();
await addDialog.getByRole("button", { name: "Save changes", exact: true }).click();
await expect(page.getByText("Widget definition not found")).not.toBeVisible();
await editToggle.click();
await expect(editToggle).toHaveAttribute("aria-pressed", "false", { timeout: 15_000 });
await expect(page.getByText("E2E Widget")).toBeVisible({ timeout: 15_000 });
await expect(page.getByText("42")).toBeVisible({ timeout: 15_000 });
} finally {
await Promise.allSettled([browser?.close(), homarrContainer?.stop(), mockApi?.close()]);
}
}, 180_000);
});
-20
View File
@@ -1,20 +0,0 @@
import { describe, expect, test } from "vitest";
import { createHomarrContainer } from "./shared/create-homarr-container";
describe("Home", () => {
test("should open with status code 200", async () => {
// Arrange
const homarrContainer = await createHomarrContainer().start();
try {
// Act
const homeResponse = await fetch(`http://localhost:${homarrContainer.getMappedPort(7575)}/`);
// Assert
expect(homeResponse.status).toBe(200);
} finally {
await homarrContainer.stop();
}
}, 60_000);
});
+3 -102
View File
@@ -1,11 +1,9 @@
import { mkdir, readFile, rm } from "node:fs/promises";
import { mkdir, rm } from "node:fs/promises";
import path from "node:path";
import { chromium } from "@playwright/test";
import type { Page } from "@playwright/test";
import { createId } from "@paralleldrive/cuid2";
import AdmZip from "adm-zip";
import type { StartedTestContainer } from "testcontainers";
import { describe, expect, test } from "vitest";
import * as sqliteSchema from "../packages/db/schema/sqlite";
@@ -30,48 +28,6 @@ const cleanupSqliteDbAsync = async ({ db, localMountPath }: SqliteTestDatabase)
await rm(localMountPath, { recursive: true, force: true });
};
const createRestoreArchiveAsync = async () => {
const sqlite = await createSqliteDbFileAsync();
const { db, localMountPath } = sqlite;
try {
await db.insert(sqliteSchema.onboarding).values({ id: "onboarding", step: "finish", previousStep: "setup" });
await db.insert(sqliteSchema.boards).values({ id: "restored-board", name: "restored", isPublic: true });
await db.insert(sqliteSchema.layouts).values({
id: "restored-base",
boardId: "restored-board",
name: "Base",
role: "base",
columnCount: 10,
breakpoint: 768,
});
await db.insert(sqliteSchema.sections).values({
id: "restored-root",
boardId: "restored-board",
kind: "empty",
xOffset: 0,
yOffset: 0,
});
db.$client.close();
const zip = new AdmZip();
zip.addFile("db.sqlite", await readFile(path.join(localMountPath, "db", "db.sqlite")));
zip.addFile(
"metadata.json",
Buffer.from(
JSON.stringify({
homarrVersion: "e2e",
exportedAt: new Date().toISOString(),
dbDialect: "sqlite",
encryptionKey: "0".repeat(64),
}),
),
);
return zip.toBuffer();
} finally {
await cleanupSqliteDbAsync(sqlite);
}
};
describe("Onboarding", () => {
test.each([
{ name: "mobile", width: 320, height: 740 },
@@ -89,7 +45,7 @@ describe("Onboarding", () => {
try {
await page.goto(`http://${homarrContainer.getHost()}:${homarrContainer.getMappedPort(7575)}/init`);
await page.getByRole("heading", { name: "Make home feel organized." }).waitFor();
await page.getByRole("heading", { name: "Welcome home." }).waitFor();
const start = page.getByRole("button", { name: "Get started" });
await start.focus();
await start.press("Enter");
@@ -180,61 +136,6 @@ describe("Onboarding", () => {
90_000,
);
test.skip("SQLite onboarding restores a validated backup and survives the restart", async () => {
const restoreArchive = await createRestoreArchiveAsync();
const invalidArchive = new AdmZip();
invalidArchive.addFile("db.sqlite", Buffer.from("not-a-database"));
const sqlite = await createSqliteDbFileAsync();
const { localMountPath } = sqlite;
const initialContainer = await createHomarrContainer({ mounts: { "/appdata": localMountPath } }).start();
let initialContainerStopped = false;
let restoredContainer: StartedTestContainer | undefined;
const browser = await chromium.launch();
const context = await browser.newContext();
const page = await context.newPage();
try {
const initialBaseUrl = `http://${initialContainer.getHost()}:${initialContainer.getMappedPort(7575)}`;
await page.goto(`${initialBaseUrl}/init`);
await page.getByRole("button", { name: "Restore backup" }).click();
const fileInput = page.locator('input[type="file"]');
await fileInput.setInputFiles({
name: "missing-metadata.zip",
mimeType: "application/zip",
buffer: invalidArchive.toBuffer(),
});
await page.getByText("Invalid backup: missing metadata.json").waitFor();
await page.getByRole("button", { name: "Change file" }).click();
await fileInput.setInputFiles({ name: "homarr-backup.zip", mimeType: "application/zip", buffer: restoreArchive });
await page.getByRole("heading", { name: "Backup Contents" }).waitFor({ timeout: 30_000 });
expect(await page.getByText("restored", { exact: true }).isVisible()).toBe(true);
await page.getByRole("button", { name: "Continue to restore" }).click();
await page.getByPlaceholder("I understand").fill("I understand");
const restoreResponse = page.waitForResponse(
(response) => response.url().endsWith("/api/backup/import") && response.request().method() === "POST",
);
await page.getByRole("button", { name: "Restore Database" }).click();
expect((await restoreResponse).status()).toBe(200);
await initialContainer.stop();
initialContainerStopped = true;
restoredContainer = await createHomarrContainer({ mounts: { "/appdata": localMountPath } }).start();
const restoredBaseUrl = `http://${restoredContainer.getHost()}:${restoredContainer.getMappedPort(7575)}`;
await page.goto(`${restoredBaseUrl}/boards/restored`);
await page.locator('[data-testid="board-canvas"][data-board-hydrated="true"]').waitFor({ timeout: 30_000 });
expect(page.url()).toMatch(/\/boards\/restored(?:[/?#]|$)/);
} finally {
try {
await browser.close();
if (!initialContainerStopped) await initialContainer.stop();
if (restoredContainer) await restoredContainer.stop();
} finally {
await cleanupSqliteDbAsync(sqlite);
}
}
}, 120_000);
test("Credentials onboarding recovers when automatic sign-in fails after account creation", async () => {
const sqlite = await createSqliteDbFileAsync();
const { db, localMountPath } = sqlite;
@@ -356,7 +257,7 @@ describe("Onboarding", () => {
);
await loginAsync({ page, baseUrl, credentials, destination: "/init" });
await page.getByRole("heading", { name: "Make home feel organized." }).waitFor();
await page.getByRole("heading", { name: "Welcome home." }).waitFor();
await page.getByRole("button", { name: "Get started" }).click();
await page.getByRole("heading", { name: "Start with familiar defaults" }).waitFor();
expect(await page.getByRole("heading", { name: "Create your administrator" }).count()).toBe(0);
-50
View File
@@ -1,50 +0,0 @@
import { createServer } from "node:http";
import { getContainerRuntimeClient, TestContainers } from "testcontainers";
import type { Server } from "node:http";
const TESTCONTAINERS_SSHD_LABEL = "org.testcontainers.sshd";
export interface MockApiServer {
url: string;
close: () => Promise<void>;
}
export async function startMockApiServerAsync(responseBody: unknown): Promise<MockApiServer> {
const body = JSON.stringify(responseBody);
const server: Server = await new Promise((resolve) => {
const candidate = createServer((_request, response) => {
response.writeHead(200, { "content-type": "application/json" });
response.end(body);
});
candidate.listen(0, "0.0.0.0", () => resolve(candidate));
});
const address = server.address();
if (!address || typeof address === "string") {
server.close();
throw new Error("Failed to start mock API server");
}
try {
await TestContainers.exposeHostPorts(address.port);
const client = await getContainerRuntimeClient();
const forwarder = (await client.container.list()).find(
(container) => container.State === "running" && container.Labels[TESTCONTAINERS_SSHD_LABEL] === "true",
);
const forwarderAddress = forwarder
? Object.values(forwarder.NetworkSettings.Networks).find((network) => network.IPAddress)?.IPAddress
: undefined;
if (!forwarderAddress) throw new Error("Failed to locate the Testcontainers host-port forwarder");
return {
url: `http://${forwarderAddress}:${address.port}`,
close: () =>
new Promise((resolve, reject) => {
server.close((error) => (error ? reject(error) : resolve()));
}),
};
} catch (error) {
server.close();
throw error;
}
}
+6 -3
View File
@@ -49,12 +49,15 @@
"scripts:update-openapi": "tsx ./scripts/check-openapi-schema.mts --write",
"scripts:update-readme-integrations": "tsx ./scripts/update-integration-list.mts",
"start": "pnpm -F nextjs start",
"test": "cross-env NODE_ENV=development CI=true vitest run --project \"!e2e\" --retry 2 --coverage.enabled ",
"test:e2e": "cross-env NODE_ENV=development CI=true vitest e2e --retry 2 --maxWorkers 2",
"test": "cross-env NODE_ENV=development CI=true vitest run --project \"!e2e\" --project \"!integration\" --project \"!docs-screenshots\"",
"test:coverage": "pnpm test --coverage.enabled",
"test:integration": "cross-env NODE_ENV=development CI=true vitest run --project integration --maxWorkers 2",
"test:docs-screenshots": "cross-env UPDATE_DOCS_SCREENSHOTS=true NODE_ENV=development CI=true vitest run --project docs-screenshots",
"test:e2e": "cross-env NODE_ENV=development CI=true vitest run --project e2e --maxWorkers 2",
"test:workshop": "sh apps/workshop/test.sh",
"test:workshop-live-provider": "node apps/workshop/tests/openrouter-live-smoke.mjs",
"test:workshop-image": "sh apps/workshop/test-image.sh",
"test:ui": "cross-env NODE_ENV=development CI=true vitest --project \"!e2e\" --ui --coverage.enabled",
"test:ui": "cross-env NODE_ENV=development CI=true vitest --project \"!e2e\" --project \"!integration\" --project \"!docs-screenshots\" --ui",
"typecheck": "turbo typecheck",
"with-env": "dotenv -e .env --"
},
+2
View File
@@ -51,11 +51,13 @@
"@homarr/validation": "workspace:^0.1.0",
"@homarr/workshop": "workspace:^0.1.0",
"@kubernetes/client-node": "catalog:",
"@modelcontextprotocol/server": "catalog:",
"@tanstack/react-query": "catalog:",
"@trpc/client": "catalog:",
"@trpc/react-query": "catalog:",
"@trpc/server": "catalog:",
"@trpc/tanstack-react-query": "catalog:",
"ajv": "catalog:",
"lodash.clonedeep": "catalog:",
"next": "catalog:",
"react": "catalog:",
+116 -62
View File
@@ -1,3 +1,5 @@
import { Ajv2020 } from "ajv/dist/2020.js";
import { addFormats, AjvJsonSchemaValidator } from "@modelcontextprotocol/server/validators/ajv";
import type { AnyProcedure, AnyRootTypes, Router, RouterRecord } from "@trpc/server/unstable-core-do-not-import";
import { z } from "zod/v4";
@@ -15,23 +17,35 @@ export interface McpTool {
type: "query" | "mutation";
pathInRouter: string[];
inputSchema: z.core.JSONSchema.JSONSchema;
inputValidator: z.ZodObject;
inputMode: "none" | "object";
}
const emptyInput = z.object({});
export interface McpToolDiagnostic {
name: string;
path: string;
reason: "unsupported_input" | "invalid_schema" | "duplicate_name" | "invalid_name";
}
export interface McpToolCatalog {
tools: McpTool[];
diagnostics: McpToolDiagnostic[];
}
const emptyInput = z.strictObject({});
const schemaEngine = new Ajv2020({
strict: false,
validateSchema: true,
coerceTypes: false,
useDefaults: false,
removeAdditional: false,
});
addFormats(schemaEngine);
const schemaValidator = new AjvJsonSchemaValidator(schemaEngine);
const isProcedure = (value: AnyProcedure | RouterRecord): value is AnyProcedure => typeof value === "function";
const mergeInputs = (inputs: z.ZodObject[]) => inputs.reduce((merged, input) => merged.extend(input.shape), emptyInput);
const getObjectInput = (input: z.ZodType) => {
const getObjectInput = (input: unknown): z.ZodObject | null => {
if (input instanceof z.ZodObject) return input;
if (input instanceof z.ZodOptional) {
const unwrappedInput = input.unwrap();
if (unwrappedInput instanceof z.ZodObject) return unwrappedInput;
}
if (input instanceof z.ZodOptional) return getObjectInput(input.unwrap());
return null;
};
@@ -50,70 +64,110 @@ const getMcpMetadata = (meta: unknown): McpMeta["mcp"] => {
return result;
};
const getProcedureInput = (inputs: z.ZodType[]) => {
if (inputs.length === 0) return emptyInput;
if (inputs.length === 1) {
const input = inputs[0] ?? emptyInput;
return getObjectInput(input) ?? input;
}
const objectInputs = inputs.map(getObjectInput);
if (objectInputs.every((input): input is z.ZodObject => input !== null)) return mergeInputs(objectInputs);
return null;
};
const normalizeJsonSchema = (schema: z.core.JSONSchema.JSONSchema): z.core.JSONSchema.JSONSchema => {
const properties = schema.properties ?? {};
const required = (schema.required ?? []).filter((key) => {
const property = properties[key];
return !property || typeof property !== "object" || !("default" in property);
});
return {
...schema,
type: "object",
properties,
required,
};
};
export function extractMcpToolsFromProcedures<TRoot extends AnyRootTypes, TRecord extends RouterRecord>(
router: Router<TRoot, TRecord>,
): McpTool[] {
): McpToolCatalog {
const tools: McpTool[] = [];
const diagnostics: McpToolDiagnostic[] = [];
const names = new Map<string, string[]>();
for (const [path, procedure] of Object.entries(router["_def"].procedures)) {
if (!isProcedure(procedure)) continue;
const definition = procedure["_def"];
if (definition.type === "subscription") continue;
const metadata = getMcpMetadata(definition.meta);
if (!metadata?.enabled) continue;
const inputs = definition.inputs.filter((input): input is z.ZodType => input instanceof z.ZodType);
const procedureInput = getProcedureInput(inputs);
if (
procedureInput === null ||
(!(procedureInput instanceof z.ZodObject) && !procedureInput.safeParse(undefined).success)
) {
console.warn(`[MCP] Procedure ${path} does not use an object input schema; using an empty schema`);
const name = metadata.name ?? path.replaceAll(".", "_");
const paths = names.get(name) ?? [];
paths.push(path);
names.set(name, paths);
if (!/^[a-zA-Z0-9_.-]{1,128}$/.test(name)) {
diagnostics.push({ name, path, reason: "invalid_name" });
continue;
}
const inputValidator = procedureInput instanceof z.ZodObject ? procedureInput : emptyInput;
tools.push({
name: metadata.name ?? path.replaceAll(".", "_"),
description: metadata.description ?? "",
type: definition.type,
pathInRouter: path.split("."),
inputValidator,
inputSchema: normalizeJsonSchema(
z.toJSONSchema(inputValidator, {
unrepresentable: "any",
}),
),
});
const inputs = definition.inputs;
let inputMode: McpTool["inputMode"] = "object";
if (inputs.every((input) => input instanceof z.ZodUndefined || input instanceof z.ZodVoid)) inputMode = "none";
const objectInputs = inputs.map(getObjectInput);
if (inputMode === "object" && !objectInputs.every((input) => input !== null)) {
diagnostics.push({ name, path, reason: "unsupported_input" });
continue;
}
try {
let input: z.ZodType = emptyInput;
if (inputMode === "object") {
const objects = objectInputs.filter((schema): schema is z.ZodObject => schema !== null);
input = objects[0] ?? emptyInput;
for (const object of objects.slice(1)) input = z.intersection(input, object);
}
const inputSchema = z.toJSONSchema(input, {
io: "input",
override: ({ zodSchema, jsonSchema }) => {
if (zodSchema["_zod"].def.type !== "string") return;
if (zodSchema["_zod"].def.checks?.some((check) => check["_zod"].def.check === "overwrite")) {
// trim/case normalization runs before these checks in tRPC. They cannot constrain raw JSON.
delete jsonSchema.minLength;
delete jsonSchema.maxLength;
delete jsonSchema.pattern;
delete jsonSchema.allOf;
delete jsonSchema.format;
}
// Zod's ISO regex permits minute precision; JSON Schema date-time requires seconds.
if (jsonSchema.format === "date-time" && typeof jsonSchema.pattern === "string") {
delete jsonSchema.format;
}
},
});
// Chained tRPC object inputs produce allOf; MCP requires an explicit object root.
if (inputSchema.type !== undefined && inputSchema.type !== "object") throw new Error("Invalid root");
inputSchema.type = "object";
// Zod and the SDK disagree on JSON Schema vocabulary types; compilation checks the actual schema.
schemaValidator.getValidator(inputSchema as Parameters<typeof schemaValidator.getValidator>[0]);
tools.push({
name,
description: metadata.description ?? "",
type: definition.type,
pathInRouter: path.split("."),
inputMode,
inputSchema,
});
} catch {
// Conversion errors can contain schema metadata. Only expose a stable, safe reason.
diagnostics.push({ name, path, reason: "invalid_schema" });
}
}
return tools.toSorted((left, right) => left.name.localeCompare(right.name));
const duplicates = new Set<string>();
for (const [name, paths] of names) {
if (paths.length < 2) continue;
duplicates.add(name);
for (const path of paths) diagnostics.push({ name, path, reason: "duplicate_name" });
}
return {
tools: tools
.filter((tool) => !duplicates.has(tool.name))
.toSorted((left, right) => left.name.localeCompare(right.name)),
diagnostics: diagnostics.toSorted(
(left, right) => left.path.localeCompare(right.path) || left.reason.localeCompare(right.reason),
),
};
}
/** Dispatch raw JSON through the caller so tRPC owns parsing, transforms, and authorization. */
export async function callMcpTool(caller: unknown, tool: McpTool, input: unknown) {
let procedure = caller;
for (const segment of tool.pathInRouter) {
if ((typeof procedure !== "object" && typeof procedure !== "function") || procedure === null) {
throw new Error("Tool is not callable");
}
procedure = (procedure as Record<string, unknown>)[segment];
}
if (typeof procedure !== "function") throw new Error("Tool is not callable");
let procedureInput = input;
if (tool.inputMode === "none") procedureInput = undefined;
else if (input === undefined) procedureInput = {};
return await (procedure as (value: unknown) => Promise<unknown>)(procedureInput);
}
+1 -1
View File
@@ -1,5 +1,5 @@
export { createTRPCContext } from "./trpc";
export { extractMcpToolsFromProcedures } from "./mcp-tools";
export { callMcpTool, extractMcpToolsFromProcedures } from "./mcp-tools";
export type { McpTool } from "./mcp-tools";
import { createTRPCRouter } from "./trpc";
+2 -2
View File
@@ -56,7 +56,7 @@ export const inviteRouter = createTRPCRouter({
.requiresPermission("admin")
.input(
z.object({
expirationDate: z.date(),
expirationDate: z.iso.datetime({ offset: true }),
}),
)
.output(z.object({ id: z.string(), token: z.string() }))
@@ -80,7 +80,7 @@ export const inviteRouter = createTRPCRouter({
await ctx.db.insert(invites).values({
id,
expirationDate: input.expirationDate,
expirationDate: new Date(input.expirationDate),
creatorId: ctx.session.user.id,
token,
});
+1 -1
View File
@@ -123,7 +123,7 @@ describe("create should create a new invite expiring on the specified date with
// Act
const result = await caller.createInvite({
expirationDate,
expirationDate: expirationDate.toISOString(),
});
// Assert
+14
View File
@@ -497,6 +497,13 @@ export const userRouter = createTRPCRouter({
mcp: { enabled: true, description: "Delete a user by ID. REQUIRED: userId (string)" },
})
.mutation(async ({ input, ctx }) => {
if (isDemoMode) {
throw new TRPCError({
code: "FORBIDDEN",
message: "User deletion is disabled in demo mode",
});
}
// Only admins and user itself can delete a user
if (ctx.session.user.id !== input.userId && !ctx.session.user.permissions.includes("admin")) {
throw new TRPCError({
@@ -519,6 +526,13 @@ export const userRouter = createTRPCRouter({
},
})
.mutation(async ({ ctx, input }) => {
if (isDemoMode) {
throw new TRPCError({
code: "FORBIDDEN",
message: "Password changes are disabled in demo mode",
});
}
const user = ctx.session.user;
// Only admins can change other users' passwords
if (!user.permissions.includes("admin") && user.id !== input.userId) {
+128 -8
View File
@@ -1,9 +1,12 @@
import { initTRPC } from "@trpc/server";
import type { TRPCError } from "@trpc/server";
import { describe, expect, test, vi } from "vitest";
import { z } from "zod/v4";
import { mcpRouter } from "../mcp";
import { extractMcpToolsFromProcedures } from "../mcp-tools";
import type { McpMeta } from "../mcp-tools";
import { createMcpProtocolHandler } from "../../../../apps/nextjs/src/app/api/mcp/_protocol";
import { callMcpTool, extractMcpToolsFromProcedures } from "../mcp-tools";
vi.mock("@homarr/auth", () => ({}));
@@ -157,7 +160,7 @@ function actualToolInventory() {
{ _def?: { type?: "query" | "mutation" } }
>;
return extractMcpToolsFromProcedures(mcpRouter)
.map((tool) => {
.tools.map((tool) => {
const procedure = procedures[tool.pathInRouter.join(".")];
const type = procedure?.["_def"]?.type;
if (type !== "query" && type !== "mutation") throw new Error(`Unable to classify MCP tool '${tool.name}'`);
@@ -175,7 +178,7 @@ describe("production MCP router", () => {
});
test("keeps every secret-bearing tool behind a mutation procedure", () => {
const tools = extractMcpToolsFromProcedures(mcpRouter);
const tools = extractMcpToolsFromProcedures(mcpRouter).tools;
const procedures = mcpRouter["_def"].procedures as unknown as Record<
string,
{ _def?: { type?: "query" | "mutation" } }
@@ -189,7 +192,7 @@ describe("production MCP router", () => {
});
test("gives every production tool a description", () => {
const tools = extractMcpToolsFromProcedures(mcpRouter);
const tools = extractMcpToolsFromProcedures(mcpRouter).tools;
for (const tool of tools) expect(tool.description, `Tool ${tool.name} should have a description`).toBeTruthy();
expect(tools.find((tool) => tool.name === "customWidget_getAuthoringPrompt")?.description).toBe(
"Get the current Custom Widget authoring instructions.",
@@ -197,16 +200,17 @@ describe("production MCP router", () => {
});
});
test("MCP tools are deterministically ordered and retain executable schemas", () => {
const tools = extractMcpToolsFromProcedures(mcpRouter);
test("MCP tools are deterministically ordered and have valid production schemas", () => {
expect(extractMcpToolsFromProcedures(mcpRouter).diagnostics).toEqual([]);
const tools = extractMcpToolsFromProcedures(mcpRouter).tools;
const toolNames = tools.map((tool) => tool.name);
expect(toolNames).toEqual(toolNames.toSorted((left, right) => left.localeCompare(right)));
expect(tools.every((tool) => tool.inputValidator instanceof z.ZodObject)).toBe(true);
expect(tools.every((tool) => tool.inputSchema.type === "object")).toBe(true);
});
test("publishes both Custom Widget template input formats", () => {
const tool = extractMcpToolsFromProcedures(mcpRouter).find(
const tool = extractMcpToolsFromProcedures(mcpRouter).tools.find(
(candidate) => candidate.name === "customWidget_validateTemplate",
);
@@ -355,3 +359,119 @@ describe("custom widget authoring procedure access", () => {
);
});
});
describe("MCP catalog diagnostics", () => {
const trpc = initTRPC.meta<McpMeta>().create();
const exposed = trpc.procedure.meta({ mcp: { enabled: true } });
test("omits unsupported parsers and malformed schemas without losing valid tools", () => {
const router = trpc.router({
good: exposed.input(z.object({ name: z.string() })).query(() => "ok"),
date: exposed.input(z.object({ date: z.date() })).query(() => "unused"),
scalar: exposed.input(z.string()).query(() => "unused"),
parser: exposed.input({ parse: (value: unknown) => value }).query(() => "unused"),
malformed: exposed
.input(z.object({ value: z.string().meta({ type: "invalid-json-type" }) }))
.query(() => "unused"),
});
const catalog = extractMcpToolsFromProcedures(router);
expect(catalog.tools.map((tool) => tool.name)).toEqual(["good"]);
expect(catalog.diagnostics).toEqual([
{ name: "date", path: "date", reason: "invalid_schema" },
{ name: "malformed", path: "malformed", reason: "invalid_schema" },
{ name: "parser", path: "parser", reason: "unsupported_input" },
{ name: "scalar", path: "scalar", reason: "unsupported_input" },
]);
});
test("omits all duplicate names even when one procedure has an invalid schema", () => {
const shared = trpc.procedure.meta({ mcp: { enabled: true, name: "shared" } });
const catalog = extractMcpToolsFromProcedures(
trpc.router({
first: shared.query(() => "first"),
second: shared.input(z.object({ date: z.date() })).query(() => "second"),
good: exposed.query(() => "good"),
invalidName: trpc.procedure.meta({ mcp: { enabled: true, name: "bad name" } }).query(() => "unused"),
}),
);
expect(catalog.tools.map((tool) => tool.name)).toEqual(["good"]);
expect(catalog.diagnostics.filter((diagnostic) => diagnostic.reason === "duplicate_name")).toEqual([
{ name: "shared", path: "first", reason: "duplicate_name" },
{ name: "shared", path: "second", reason: "duplicate_name" },
]);
expect(catalog.diagnostics).toContainEqual({ name: "bad name", path: "invalidName", reason: "invalid_name" });
});
test("distinguishes empty object input from no input for shared Assistant dispatch", async () => {
const router = trpc.router({
none: exposed.query(({ input }) => input),
explicitNone: exposed.input(z.undefined()).query(({ input }) => input),
object: exposed.input(z.object({ value: z.string().default("default") })).query(({ input }) => input),
optional: exposed
.input(z.object({ value: z.string().default("default") }).optional())
.query(({ input }) => input),
});
const catalog = extractMcpToolsFromProcedures(router);
expect(catalog.diagnostics).toEqual([]);
for (const tool of catalog.tools) {
const result = await callMcpTool(router.createCaller({}), tool, {});
if (tool.inputMode === "none") expect(result).toBeUndefined();
else expect(result).toEqual({ value: "default" });
}
});
});
test.each([false, true])(
"production catalog registers through the real protocol handler (modern=%s)",
async (modern) => {
const { tools, diagnostics } = extractMcpToolsFromProcedures(mcpRouter);
expect(diagnostics).toEqual([]);
const handler = createMcpProtocolHandler({
caller: {},
tools,
version: "test",
instructions: "test",
formatToolError: () => "Tool failed",
});
const headers: Record<string, string> = {
"Content-Type": "application/json",
Accept: "application/json, text/event-stream",
};
const params: Record<string, unknown> = {};
if (modern) {
headers["Mcp-Method"] = "tools/list";
headers["Mcp-Name"] = "homarr";
params["_meta"] = {
"io.modelcontextprotocol/protocolVersion": "2026-07-28",
"io.modelcontextprotocol/clientInfo": { name: "catalog-test", version: "1" },
"io.modelcontextprotocol/clientCapabilities": {},
};
}
const response = await handler.fetch(
new Request("http://homarr.test/api/mcp", {
method: "POST",
headers,
body: JSON.stringify({ jsonrpc: "2.0", id: 1, method: "tools/list", params }),
}),
);
expect(response.status).toBe(200);
expect(response.headers.has("Mcp-Session-Id")).toBe(false);
const body = await response.text();
let payload = body;
if (response.headers.get("Content-Type")?.includes("text/event-stream")) {
payload =
body
.split("\n")
.find((line) => line.startsWith("data: "))
?.slice(6) ?? "";
}
const result = JSON.parse(payload) as {
error?: unknown;
result: { tools: { name: string; inputSchema: unknown }[] };
};
expect(result.error).toBeUndefined();
expect(result.result.tools.map((tool) => ({ name: tool.name, inputSchema: tool.inputSchema }))).toEqual(
tools.map((tool) => ({ name: tool.name, inputSchema: tool.inputSchema })),
);
},
);
@@ -37,12 +37,6 @@ describe("authoring resources", () => {
expect(bundledContent).toContain(content.trim());
}
expect(bundledContent).toContain(skill.skillMd.trim());
expect(bundledContent).toContain("coordinated set of widgets");
expect(bundledContent).toContain("customWidget_validateTemplate");
expect(bundledContent).toContain("customWidget_findComponents");
expect(bundledContent).toContain("Batch selected non-obvious binding or interaction documentation");
expect(bundledContent).not.toContain("at most eight named component documents");
expect(bundledContent).toContain("Do not use an unlabeled decorative icon as an empty state");
expect(Buffer.byteLength(JSON.stringify(skill), "utf8")).toBeLessThan(12_000);
const entrypoint = getCustomWidgetSkillEntrypoint();
@@ -50,36 +44,7 @@ describe("authoring resources", () => {
expect.arrayContaining([expect.objectContaining({ name: "runtime", tool: "customWidget_getReference" })]),
);
expect(JSON.stringify(entrypoint)).not.toContain("# Runtime");
expect(entrypoint.skillMd).toContain("Context routing");
expect(entrypoint.skillMd).toContain("customWidget_validateTemplate");
expect(entrypoint.skillMd).toContain("customWidget_previewReviseTemplate");
expect(entrypoint.skillMd).toContain("map every requested capability");
expect(entrypoint.skillMd).toContain("purpose-specific visual signature");
expect(entrypoint.skillMd).toContain("asymmetric priority metric/action");
expect(entrypoint.skillMd).toContain("new manifest");
expect(entrypoint.skillMd).not.toContain("To install an existing widget");
expect(getCustomWidgetSkillReference("runtime").content).toContain("SubFetch");
expect(getCustomWidgetSkillReference("runtime").content).toContain(
'require `trigger: "manual"` on request and `SubFetch`',
);
expect(getCustomWidgetSkillReference("runtime").content).toContain("RefreshButton");
expect(getCustomWidgetSkillReference("runtime").content).toContain("owns loading/error/retry");
expect(getCustomWidgetSkillReference("runtime").content).toContain("need literal `requestId`");
expect(getCustomWidgetSkillReference("runtime").content).toContain('requestId="search" label="Run again"');
expect(getCustomWidgetSkillReference("runtime").content).toContain("Render load queries directly");
expect(getCustomWidgetSkillReference("runtime").content).toContain("entire JSON response");
expect(getCustomWidgetSkillReference("runtime").content).toContain("never map the envelope itself");
expect(getCustomWidgetSkillReference("runtime").content).toContain("immediately hides its prior result");
expect(getCustomWidgetSkillReference("runtime").content).toContain("Every stateful control must use `bind`");
expect(getCustomWidgetSkillReference("runtime").content).toContain("resetKey={inputs.search}");
expect(getCustomWidgetSkillReference("runtime").content).toContain('Date.toLocaleString(value, "en-US", "UTC")');
expect(getCustomWidgetSkillReference("runtime").content).toContain("Never invent a formatter component");
const schemaReference = getCustomWidgetSkillReference("schema").content;
expect(schemaReference).toContain('"default": {');
expect(schemaReference).toContain('"trigger": "manual"');
expect(schemaReference).toContain('"kind": "action"');
expect(schemaReference).toContain('"apiKeyHeader"');
expect(schemaReference).toContain("is the required source ID");
expect(Buffer.byteLength(schemaReference, "utf8")).toBeLessThan(4_000);
expect(Buffer.byteLength(JSON.stringify(entrypoint), "utf8")).toBeLessThan(3_000);
expect(Buffer.byteLength(JSON.stringify(entrypoint), "utf8")).toBeLessThan(
@@ -107,8 +72,6 @@ describe("authoring resources", () => {
const serviceDashboard = getCustomWidgetExample("service-dashboard");
if (!serviceDashboard) throw new Error("Service dashboard example is missing");
expect(validateCustomJsxTemplate(serviceDashboard.widget.template)).toEqual([]);
expect(serviceDashboard.widget.template.match(/<Paper/gu)).toHaveLength(1);
expect(serviceDashboard.widget.template).toContain('RefreshButton label="Refresh service health"');
expect(Buffer.byteLength(JSON.stringify(pokedex), "utf8")).toBeLessThan(16_000);
expect(catalog.sharedProps).toMatchObject({
count: expect.any(Number),
@@ -1,74 +0,0 @@
import { readFile } from "node:fs/promises";
import { resolve } from "node:path";
import { describe, expect, test } from "vitest";
const languageDirectory = resolve(import.meta.dirname, "../../../translation/src/lang");
describe("Workshop navigation translations", () => {
test("defines the breadcrumb labels for the Custom Widgets sub-routes", async () => {
const messages = JSON.parse(await readFile(resolve(languageDirectory, "en.json"), "utf8")) as {
management?: { navbar?: { items?: { workshop?: unknown } } };
navigationStructure?: {
manage?: {
workshop?: unknown;
"custom-widgets"?: {
workshop?: { label?: unknown };
publish?: { label?: unknown };
};
};
};
customWidget?: { page?: { tabs?: Record<string, unknown> } };
workshop?: Record<string, unknown>;
};
// The Workshop is reached through the Custom Widgets tabs, not its own navbar entry.
expect(messages.management?.navbar?.items?.workshop).toBeUndefined();
expect(messages.navigationStructure?.manage?.workshop).toBeUndefined();
expect(messages.navigationStructure?.manage?.["custom-widgets"]?.workshop?.label).toBe("Workshop");
expect(messages.navigationStructure?.manage?.["custom-widgets"]?.publish?.label).toBe("Publish to Workshop");
expect(messages.customWidget?.page?.tabs).toMatchObject({
installed: "Installed",
workshop: "Workshop",
ariaLabel: expect.any(String),
});
expect(messages.workshop).toMatchObject({
communityMember: "Community member",
outdated: "Outdated",
install: "Install",
back: expect.any(String),
listAriaLabel: expect.any(String),
installReviewTitle: expect.any(String),
emptyFilteredDescription: expect.any(String),
importCssConfirm: expect.any(String),
reportCount: expect.any(String),
reportWarning: expect.any(String),
reportVisibility: expect.any(String),
screenshotAlt: expect.any(String),
upvote: expect.any(String),
downvote: expect.any(String),
reportCategory: expect.objectContaining({
outdated: expect.any(String),
malicious: expect.any(String),
spam: expect.any(String),
copyright: expect.any(String),
inappropriate: expect.any(String),
other: expect.any(String),
}),
publish: expect.objectContaining({
invalidScreenshot: expect.any(String),
}),
});
});
test("drops the message keys that only existed for the stacked install modals", async () => {
const messages = JSON.parse(await readFile(resolve(languageDirectory, "en.json"), "utf8")) as {
workshop?: Record<string, unknown>;
};
for (const key of ["installDialog", "continueInstall", "confirmInstallTitle", "backToWidget", "inspectCss"]) {
expect(messages.workshop).not.toHaveProperty(key);
}
});
});
@@ -12,13 +12,13 @@ import {
Text,
ThemeIcon,
} from "@mantine/core";
import { IconListCheck, IconPlus, IconSearch } from "@tabler/icons-react";
import { IconBulb, IconListCheck, IconPlus, IconSearch } from "@tabler/icons-react";
import type { RouterOutputs } from "@homarr/api";
import { clientApi } from "@homarr/api/client";
import { createModal, modalSizeSelect, useModalAction } from "@homarr/modals";
import { useI18n } from "@homarr/translation/client";
import { selectGridCols, SelectableCard } from "@homarr/ui";
import { FloatingTip, selectGridCols, SelectableCard } from "@homarr/ui";
import { QuickAddAppModal } from "./quick-add-app/quick-add-app-modal";
@@ -106,6 +106,17 @@ export const AppSelectModal = createModal<AppSelectModalProps>(({ actions, inner
return (
<Stack gap="md">
<FloatingTip
opened={multiSelectAvailable}
showDelay={2_000}
dismissAfter={3_000}
transitionDuration={200}
closable={false}
alertProps={{ color: "primaryColor", icon: <IconBulb size={18} />, variant: "light" }}
>
{t("tips.multiSelectApps")}
</FloatingTip>
{/* Top Search Input */}
<Stack gap={6}>
<Input
+2 -2
View File
@@ -27,11 +27,11 @@
"review": "Review"
},
"welcome": {
"title": "Make home feel organized.",
"title": "Welcome home.",
"description": "Welcome to Homarr v{version}. We’ll guide you through the essentials and help you build your first board.",
"start": "Get started",
"restore": "Restore backup",
"restoreTitle": "Restore this Homarr",
"restoreTitle": "Restore from backup",
"restoreDescription": "A full SQLite backup restores boards, people, apps, integrations, and settings. Homarr validates it before replacing anything.",
"errorTitle": "Setup could not start",
"errorDescription": "Your setup choices are still here. Try again, or reload the page if this continues."
+9
View File
@@ -366,6 +366,9 @@ catalogs:
ai:
specifier: ^7.0.56
version: 7.0.56
ajv:
specifier: 8.18.0
version: 8.18.0
assistant-stream:
specifier: 0.3.34
version: 0.3.34
@@ -1520,6 +1523,9 @@ importers:
'@kubernetes/client-node':
specifier: 'catalog:'
version: 1.4.0(supports-color@10.2.2)
'@modelcontextprotocol/server':
specifier: 'catalog:'
version: 2.0.0
'@tanstack/react-query':
specifier: 'catalog:'
version: 5.101.2(react@19.2.7)
@@ -1535,6 +1541,9 @@ importers:
'@trpc/tanstack-react-query':
specifier: 'catalog:'
version: 11.18.0(@tanstack/react-query@5.101.2(react@19.2.7))(@trpc/client@11.18.0(@trpc/server@11.18.0(typescript@7.0.2))(typescript@7.0.2))(@trpc/server@11.18.0(typescript@7.0.2))(react@19.2.7)(typescript@7.0.2)
ajv:
specifier: 'catalog:'
version: 8.18.0
lodash.clonedeep:
specifier: 'catalog:'
version: 4.5.0
+1
View File
@@ -123,6 +123,7 @@ catalog:
adm-zip: 0.6.0
acorn: ^8.16.0
acorn-jsx: ^5.3.2
ajv: 8.18.0
jszip: ^3.10.1
sql.js: ^1.14.1
babel-plugin-react-compiler: ^1.0.0
+34
View File
@@ -2,6 +2,20 @@ import react from "@vitejs/plugin-react";
import tsconfigPaths from "vite-tsconfig-paths";
import { configDefaults, defineConfig } from "vitest/config";
// Docker-backed suites are opt-in through pnpm test:integration.
const integrationTests = [
"**/*.integration.spec.ts",
"packages/db/test/mysql-migration.spec.ts",
"packages/db/test/postgresql-migration.spec.ts",
"packages/integrations/test/aria2.spec.ts",
"packages/integrations/test/home-assistant.spec.ts",
"packages/integrations/test/nextcloud.spec.ts",
"packages/integrations/test/nzbget.spec.ts",
"packages/integrations/test/pi-hole.spec.ts",
"packages/integrations/test/sabnzbd.spec.ts",
"packages/integrations/test/technitium.spec.ts",
];
export default defineConfig({
plugins: [react(), tsconfigPaths()],
test: {
@@ -33,6 +47,7 @@ export default defineConfig({
environment: "node",
setupFiles: ["./vitest.setup.ts", "./vitest.setup.node.ts"],
include: ["packages/db/test/**/*.spec.ts"],
exclude: [...configDefaults.exclude, ...integrationTests],
},
},
{
@@ -77,15 +92,34 @@ export default defineConfig({
"packages/docker/**",
"packages/request-handler/**",
"e2e/**",
...integrationTests,
],
},
},
{
extends: true,
test: {
name: "integration",
environment: "node",
setupFiles: ["./vitest.setup.ts", "./vitest.setup.node.ts"],
include: integrationTests,
},
},
{
extends: true,
test: {
name: "e2e",
environment: "node",
include: ["e2e/**/*.spec.ts"],
exclude: [...configDefaults.exclude, "e2e/assistant-docs-screenshots.spec.ts"],
},
},
{
extends: true,
test: {
name: "docs-screenshots",
environment: "node",
include: ["e2e/assistant-docs-screenshots.spec.ts"],
},
},
],