Compare commits
4
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
cc015463f3 | ||
|
|
4df618e418 | ||
|
|
dba4c27ea5 | ||
|
|
6f2bcb8054 |
No files matched your search
+57
-10
@@ -97,16 +97,17 @@ jobs:
|
||||
|
||||
- name: Validate Custom Widget bundle budget
|
||||
run: |
|
||||
pnpm turbo run build --filter=@homarr/nextjs
|
||||
pnpm --filter nextjs build
|
||||
pnpm check:custom-widget-bundle
|
||||
|
||||
- name: Run unit tests
|
||||
run: pnpm test
|
||||
|
||||
container:
|
||||
name: Container build
|
||||
# Same-repository previews already build amd64; only build a fallback when no digest is available.
|
||||
name: Container and E2E
|
||||
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') ||
|
||||
@@ -114,9 +115,10 @@ jobs:
|
||||
(github.event_name == 'workflow_dispatch' && (inputs.operation == 'validate' || inputs.operation == 'publish'))
|
||||
)
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 20
|
||||
timeout-minutes: 30
|
||||
permissions:
|
||||
contents: read
|
||||
packages: read
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
if: "!env.ACT"
|
||||
@@ -124,10 +126,23 @@ jobs:
|
||||
persist-credentials: false
|
||||
ref: ${{ github.event_name == 'workflow_dispatch' && inputs.ref || '' }}
|
||||
|
||||
- name: Setup
|
||||
uses: ./tooling/github/setup
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
if: needs.preview-amd64.outputs.digest == '' && !env.ACT
|
||||
if: "!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
|
||||
@@ -137,7 +152,9 @@ jobs:
|
||||
platforms: linux/amd64
|
||||
load: true
|
||||
push: false
|
||||
tags: homarr-ci
|
||||
tags: |
|
||||
homarr-ci
|
||||
homarr-e2e
|
||||
labels: |
|
||||
org.opencontainers.image.source=${{ github.server_url }}/${{ github.repository }}
|
||||
org.opencontainers.image.revision=${{ github.sha }}
|
||||
@@ -165,7 +182,37 @@ jobs:
|
||||
fi
|
||||
done
|
||||
DOCKER_BUILDKIT=1 docker build --platform linux/amd64 \
|
||||
--tag homarr-ci "${secret_args[@]}" .
|
||||
--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
|
||||
|
||||
preview-amd64:
|
||||
name: Build amd64 preview image
|
||||
@@ -273,10 +320,10 @@ jobs:
|
||||
|
||||
publish-preview:
|
||||
name: Publish multi-platform preview image
|
||||
needs: [fast-gate, preview-amd64, preview-arm64]
|
||||
needs: [container, preview-amd64, preview-arm64]
|
||||
if: |
|
||||
!cancelled() &&
|
||||
needs.fast-gate.result == 'success' &&
|
||||
needs.container.result == 'success' &&
|
||||
needs.preview-amd64.result == 'success' &&
|
||||
needs.preview-arm64.result == 'success' &&
|
||||
(
|
||||
|
||||
@@ -2,36 +2,8 @@ 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
|
||||
@@ -43,10 +15,21 @@ concurrency:
|
||||
jobs:
|
||||
validate:
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 5
|
||||
timeout-minutes: 30
|
||||
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')
|
||||
|
||||
@@ -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
|
||||
|
||||
Database migrations run automatically when Homarr starts. Create a
|
||||
Follow the [v1 to v2 upgrade guide](/docs/getting-started/upgrade-v2) and 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.
|
||||
|
||||
@@ -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 [v2 beta setup guide](/docs/getting-started/upgrade-v2).
|
||||
- Follow the [v1 to v2 upgrade 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,25 +82,3 @@ 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.
|
||||
@@ -1,5 +1,5 @@
|
||||
---
|
||||
title: Try the v2 beta
|
||||
title: Upgrade from v1 to v2
|
||||
sidebar_position: 2
|
||||
tags:
|
||||
- Getting started
|
||||
@@ -8,20 +8,8 @@ tags:
|
||||
- Backup
|
||||
---
|
||||
|
||||
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.
|
||||
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.
|
||||
|
||||
:::danger Back up before starting v2
|
||||
|
||||
@@ -30,23 +18,21 @@ before starting v2. See [Backup and restore](/docs/management/backup) for detail
|
||||
|
||||
:::
|
||||
|
||||
## Try the beta with existing v1 data
|
||||
## Upgrade
|
||||
|
||||
1. Change the Homarr image to `ghcr.io/homarr-labs/homarr-test:v2`.
|
||||
1. Pin the Homarr image to `ghcr.io/homarr-labs/homarr:v2.0.0` for the first v2 start.
|
||||
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. Keep using the beta image for beta updates. To return to v1, restore the pre-beta backup as described below.
|
||||
5. After validation, keep the version pinned or return to your normal stable release policy.
|
||||
|
||||
Keep your other existing Compose settings, including environment variables. For Docker Compose, update the image and preview Workshop URL before the usual update:
|
||||
For Docker Compose, the change is limited to the image tag before the usual update:
|
||||
|
||||
```yaml
|
||||
services:
|
||||
homarr:
|
||||
image: ghcr.io/homarr-labs/homarr-test:v2
|
||||
environment:
|
||||
WORKSHOP_API_URL: https://v2.preview.homarr.dev
|
||||
image: ghcr.io/homarr-labs/homarr:v2.0.0
|
||||
```
|
||||
|
||||
```sh
|
||||
|
||||
@@ -27,12 +27,6 @@ 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:
|
||||
|
||||
@@ -8,17 +8,18 @@ tags:
|
||||
|
||||
# Model Context Protocol (MCP)
|
||||
|
||||
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.
|
||||
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 a client
|
||||
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.
|
||||
|
||||
Create an API key under **Management → Tools → API → Authentication**. Add this configuration to your client,
|
||||
replacing the URL and API key:
|
||||
## Configure a client
|
||||
|
||||
Create an API key under **Management → Tools → API → Authentication**, then add the endpoint and header to the client's
|
||||
MCP configuration:
|
||||
|
||||
```json
|
||||
{
|
||||
@@ -33,10 +34,7 @@ replacing the URL and API key:
|
||||
}
|
||||
```
|
||||
|
||||
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`:
|
||||
Clients without Streamable HTTP support can use `mcp-remote` over STDIO:
|
||||
|
||||
```json
|
||||
{
|
||||
@@ -49,30 +47,26 @@ For clients that only support STDIO, use `mcp-remote`:
|
||||
}
|
||||
```
|
||||
|
||||
### OAuth
|
||||
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.
|
||||
|
||||
Clients with OAuth support can authorize through OAuth 2.1 with PKCE instead of an API key.
|
||||
Homarr publishes discovery metadata under `/.well-known/`.
|
||||
## Authentication and permissions
|
||||
|
||||
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.
|
||||
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.
|
||||
|
||||
## Permissions and tools
|
||||
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.
|
||||
|
||||
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.
|
||||
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.
|
||||
|
||||
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.
|
||||
Use HTTPS when the endpoint crosses an untrusted network.
|
||||
|
||||
The built-in [Assistant](./assistant) uses the same tool catalog with the current Homarr session and asks for approval
|
||||
before mutations by default.
|
||||
## Verify the endpoint
|
||||
|
||||
## Verify the connection
|
||||
|
||||
This request lists tools through the older-client compatibility path. It checks authentication and tool discovery:
|
||||
The 2025 compatibility path accepts a compact `tools/list` request:
|
||||
|
||||
```sh
|
||||
curl -X POST \
|
||||
@@ -83,14 +77,17 @@ curl -X POST \
|
||||
https://homarr.example.com/api/mcp
|
||||
```
|
||||
|
||||
A successful discovery or initialization response alone does not verify that tools can be listed.
|
||||
The removed HTTP+SSE `/sse` and `/message` transports return `410 Gone`.
|
||||
|
||||
## Troubleshooting
|
||||
## Tools and resources
|
||||
|
||||
- **`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.
|
||||
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.
|
||||
+1
-3
@@ -2572,9 +2572,7 @@
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"expirationDate": {
|
||||
"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)))$"
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
|
||||
@@ -1,12 +1,43 @@
|
||||
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");
|
||||
});
|
||||
});
|
||||
+36
@@ -0,0 +1,36 @@
|
||||
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,");
|
||||
});
|
||||
});
|
||||
+11
@@ -1,5 +1,6 @@
|
||||
// @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";
|
||||
@@ -75,6 +76,16 @@ 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(
|
||||
|
||||
+1
-1
@@ -45,7 +45,7 @@ export const InviteCreateForm = ({ onClose }: InviteCreateFormProps) => {
|
||||
onSubmit={form.onSubmit((values) => {
|
||||
mutate(
|
||||
{
|
||||
expirationDate: new Date(values.expirationDate).toISOString(),
|
||||
expirationDate: new Date(values.expirationDate),
|
||||
},
|
||||
{
|
||||
onSuccess: (result) => {
|
||||
|
||||
@@ -7,7 +7,7 @@ import { createUIMessageStream, createUIMessageStreamResponse, jsonSchema, stepC
|
||||
import { cookies } from "next/headers";
|
||||
import { z } from "zod/v4";
|
||||
|
||||
import { callMcpTool, createTRPCContext, mcpRouter } from "@homarr/api/mcp";
|
||||
import { createTRPCContext, mcpRouter } from "@homarr/api/mcp";
|
||||
import {
|
||||
createAssistantGenerationAccessToken,
|
||||
getAssistantRequestContextEntitiesAsync,
|
||||
@@ -547,7 +547,19 @@ export async function POST(request: Request) {
|
||||
}
|
||||
if (contextRequestKey !== null) loadedCustomWidgetContextRequests.add(contextRequestKey);
|
||||
try {
|
||||
const result = await callMcpTool(caller, mcpTool, executionInput);
|
||||
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,
|
||||
);
|
||||
customWidgetDiscoveryPhase.observe(mcpTool.name, result);
|
||||
return toAssistantToolOutput(result, {
|
||||
maxCharacters: getAssistantToolOutputMaxCharacters(mcpTool.name),
|
||||
|
||||
@@ -2,11 +2,8 @@
|
||||
|
||||
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(),
|
||||
@@ -17,9 +14,7 @@ const mocks = vi.hoisted(() => ({
|
||||
}));
|
||||
|
||||
vi.mock("next/server", () => ({ userAgent: () => ({ ua: "MCP route test" }) }));
|
||||
vi.mock("@homarr/api/mcp", async () => ({
|
||||
callMcpTool: (await import("../../../../../../../packages/api/src/mcp-tools")).callMcpTool,
|
||||
extractMcpToolsFromProcedures: mocks.extractCatalog,
|
||||
vi.mock("@homarr/api/mcp", () => ({
|
||||
createTRPCContext: vi.fn(() => ({})),
|
||||
mcpRouter: { createCaller: vi.fn(() => ({ board: { getAllBoards: mocks.toolProcedure } })) },
|
||||
}));
|
||||
@@ -59,7 +54,6 @@ vi.mock("../_extract-tools", () => ({
|
||||
description: "List boards",
|
||||
type: "query",
|
||||
pathInRouter: ["board", "getAllBoards"],
|
||||
inputMode: "none",
|
||||
inputSchema: { type: "object", properties: {} },
|
||||
},
|
||||
{
|
||||
@@ -67,7 +61,6 @@ vi.mock("../_extract-tools", () => ({
|
||||
description: "List Custom Widgets",
|
||||
type: "query",
|
||||
pathInRouter: ["customWidget", "list"],
|
||||
inputMode: "none",
|
||||
inputSchema: { type: "object", properties: {} },
|
||||
},
|
||||
{
|
||||
@@ -75,7 +68,6 @@ vi.mock("../_extract-tools", () => ({
|
||||
description: "Search Workshop",
|
||||
type: "query",
|
||||
pathInRouter: ["customWidget", "workshopSearch"],
|
||||
inputMode: "none",
|
||||
inputSchema: { type: "object", properties: {} },
|
||||
},
|
||||
],
|
||||
@@ -313,21 +305,3 @@ 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,12 +1,11 @@
|
||||
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.tools;
|
||||
if (cache) return cache;
|
||||
|
||||
cache = extractMcpToolsFromProcedures(mcpRouter);
|
||||
for (const diagnostic of cache.diagnostics) logger.warn("MCP tool omitted from catalog", diagnostic);
|
||||
return cache.tools;
|
||||
|
||||
return cache;
|
||||
}
|
||||
@@ -1,18 +1,12 @@
|
||||
// @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" },
|
||||
@@ -58,7 +52,7 @@ const echoTool: McpTool = {
|
||||
description: "Echo a message",
|
||||
type: "query",
|
||||
pathInRouter: ["echo"],
|
||||
inputMode: "object",
|
||||
inputValidator: echoValidator,
|
||||
inputSchema: z.toJSONSchema(echoValidator),
|
||||
};
|
||||
|
||||
@@ -169,197 +163,3 @@ 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"' }],
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -1,6 +1,5 @@
|
||||
import { createMcpHandler, fromJsonSchema, McpServer } from "@modelcontextprotocol/server";
|
||||
import { createMcpHandler, 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";
|
||||
@@ -16,6 +15,15 @@ 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,
|
||||
@@ -44,11 +52,20 @@ export const createMcpProtocolHandler = ({
|
||||
tool.name,
|
||||
{
|
||||
description: tool.description,
|
||||
inputSchema: fromJsonSchema(tool.inputSchema as Parameters<typeof fromJsonSchema>[0]),
|
||||
inputSchema: tool.inputValidator,
|
||||
},
|
||||
async (args) => {
|
||||
try {
|
||||
const result = await callMcpTool(caller, tool, args);
|
||||
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);
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
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,9 +1,18 @@
|
||||
// @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 = "";
|
||||
@@ -30,4 +39,136 @@ 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();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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. 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.
|
||||
the combined production image.
|
||||
|
||||
## Data safety
|
||||
|
||||
|
||||
@@ -1,7 +1,48 @@
|
||||
import { readFile } from "node:fs/promises";
|
||||
import { access, readFile } from "node:fs/promises";
|
||||
import { resolve } from "node:path";
|
||||
import vm from "node:vm";
|
||||
|
||||
const read = (path) => readFile(path, "utf8");
|
||||
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 hookUtilsModule = { exports: {} };
|
||||
vm.runInNewContext(await read("apps/workshop/pb_hooks/workshop-utils.js"), { module: hookUtilsModule });
|
||||
@@ -28,4 +69,73 @@ for (const expected of [
|
||||
}
|
||||
if (socialHtml.includes("Homarr documentation")) throw new Error("Workshop social metadata must replace generic tags");
|
||||
|
||||
console.log("Workshop social metadata rendering passed");
|
||||
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");
|
||||
@@ -16,7 +16,8 @@ const adminCredentials = {
|
||||
password: "Comp(exP4sswOrd",
|
||||
};
|
||||
|
||||
// Run this opt-in asset generator with pnpm test:docs-screenshots when the documented UI changes.
|
||||
// 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.
|
||||
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();
|
||||
|
||||
@@ -0,0 +1,114 @@
|
||||
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);
|
||||
});
|
||||
@@ -0,0 +1,20 @@
|
||||
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);
|
||||
});
|
||||
+100
-1
@@ -1,9 +1,11 @@
|
||||
import { mkdir, rm } from "node:fs/promises";
|
||||
import { mkdir, readFile, 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";
|
||||
@@ -28,6 +30,48 @@ 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 },
|
||||
@@ -136,6 +180,61 @@ 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;
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
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;
|
||||
}
|
||||
}
|
||||
+3
-6
@@ -49,15 +49,12 @@
|
||||
"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\" --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": "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: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\" --project \"!integration\" --project \"!docs-screenshots\" --ui",
|
||||
"test:ui": "cross-env NODE_ENV=development CI=true vitest --project \"!e2e\" --ui --coverage.enabled",
|
||||
"typecheck": "turbo typecheck",
|
||||
"with-env": "dotenv -e .env --"
|
||||
},
|
||||
|
||||
@@ -51,13 +51,11 @@
|
||||
"@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:",
|
||||
|
||||
+61
-115
@@ -1,5 +1,3 @@
|
||||
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";
|
||||
|
||||
@@ -17,35 +15,23 @@ export interface McpTool {
|
||||
type: "query" | "mutation";
|
||||
pathInRouter: string[];
|
||||
inputSchema: z.core.JSONSchema.JSONSchema;
|
||||
inputMode: "none" | "object";
|
||||
inputValidator: z.ZodObject;
|
||||
}
|
||||
|
||||
export interface McpToolDiagnostic {
|
||||
name: string;
|
||||
path: string;
|
||||
reason: "unsupported_input" | "invalid_schema" | "duplicate_name" | "invalid_name";
|
||||
}
|
||||
const emptyInput = z.object({});
|
||||
|
||||
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 getObjectInput = (input: unknown): z.ZodObject | null => {
|
||||
const mergeInputs = (inputs: z.ZodObject[]) => inputs.reduce((merged, input) => merged.extend(input.shape), emptyInput);
|
||||
|
||||
const getObjectInput = (input: z.ZodType) => {
|
||||
if (input instanceof z.ZodObject) return input;
|
||||
if (input instanceof z.ZodOptional) return getObjectInput(input.unwrap());
|
||||
|
||||
if (input instanceof z.ZodOptional) {
|
||||
const unwrappedInput = input.unwrap();
|
||||
if (unwrappedInput instanceof z.ZodObject) return unwrappedInput;
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
@@ -64,110 +50,70 @@ 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>,
|
||||
): McpToolCatalog {
|
||||
): McpTool[] {
|
||||
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 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 inputs = definition.inputs.filter((input): input is z.ZodType => input instanceof z.ZodType);
|
||||
const procedureInput = getProcedureInput(inputs);
|
||||
|
||||
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;
|
||||
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 inputValidator = procedureInput instanceof z.ZodObject ? procedureInput : emptyInput;
|
||||
|
||||
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" });
|
||||
}
|
||||
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 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);
|
||||
return tools.toSorted((left, right) => left.name.localeCompare(right.name));
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
export { createTRPCContext } from "./trpc";
|
||||
export { callMcpTool, extractMcpToolsFromProcedures } from "./mcp-tools";
|
||||
export { extractMcpToolsFromProcedures } from "./mcp-tools";
|
||||
export type { McpTool } from "./mcp-tools";
|
||||
|
||||
import { createTRPCRouter } from "./trpc";
|
||||
|
||||
@@ -56,7 +56,7 @@ export const inviteRouter = createTRPCRouter({
|
||||
.requiresPermission("admin")
|
||||
.input(
|
||||
z.object({
|
||||
expirationDate: z.iso.datetime({ offset: true }),
|
||||
expirationDate: z.date(),
|
||||
}),
|
||||
)
|
||||
.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: new Date(input.expirationDate),
|
||||
expirationDate: input.expirationDate,
|
||||
creatorId: ctx.session.user.id,
|
||||
token,
|
||||
});
|
||||
|
||||
@@ -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.toISOString(),
|
||||
expirationDate,
|
||||
});
|
||||
|
||||
// Assert
|
||||
|
||||
@@ -1,12 +1,9 @@
|
||||
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 type { McpMeta } from "../mcp-tools";
|
||||
import { createMcpProtocolHandler } from "../../../../apps/nextjs/src/app/api/mcp/_protocol";
|
||||
import { callMcpTool, extractMcpToolsFromProcedures } from "../mcp-tools";
|
||||
import { extractMcpToolsFromProcedures } from "../mcp-tools";
|
||||
|
||||
vi.mock("@homarr/auth", () => ({}));
|
||||
|
||||
@@ -160,7 +157,7 @@ function actualToolInventory() {
|
||||
{ _def?: { type?: "query" | "mutation" } }
|
||||
>;
|
||||
return extractMcpToolsFromProcedures(mcpRouter)
|
||||
.tools.map((tool) => {
|
||||
.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}'`);
|
||||
@@ -178,7 +175,7 @@ describe("production MCP router", () => {
|
||||
});
|
||||
|
||||
test("keeps every secret-bearing tool behind a mutation procedure", () => {
|
||||
const tools = extractMcpToolsFromProcedures(mcpRouter).tools;
|
||||
const tools = extractMcpToolsFromProcedures(mcpRouter);
|
||||
const procedures = mcpRouter["_def"].procedures as unknown as Record<
|
||||
string,
|
||||
{ _def?: { type?: "query" | "mutation" } }
|
||||
@@ -192,7 +189,7 @@ describe("production MCP router", () => {
|
||||
});
|
||||
|
||||
test("gives every production tool a description", () => {
|
||||
const tools = extractMcpToolsFromProcedures(mcpRouter).tools;
|
||||
const tools = extractMcpToolsFromProcedures(mcpRouter);
|
||||
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.",
|
||||
@@ -200,17 +197,16 @@ describe("production MCP router", () => {
|
||||
});
|
||||
});
|
||||
|
||||
test("MCP tools are deterministically ordered and have valid production schemas", () => {
|
||||
expect(extractMcpToolsFromProcedures(mcpRouter).diagnostics).toEqual([]);
|
||||
const tools = extractMcpToolsFromProcedures(mcpRouter).tools;
|
||||
test("MCP tools are deterministically ordered and retain executable schemas", () => {
|
||||
const tools = extractMcpToolsFromProcedures(mcpRouter);
|
||||
const toolNames = tools.map((tool) => tool.name);
|
||||
|
||||
expect(toolNames).toEqual(toolNames.toSorted((left, right) => left.localeCompare(right)));
|
||||
expect(tools.every((tool) => tool.inputSchema.type === "object")).toBe(true);
|
||||
expect(tools.every((tool) => tool.inputValidator instanceof z.ZodObject)).toBe(true);
|
||||
});
|
||||
|
||||
test("publishes both Custom Widget template input formats", () => {
|
||||
const tool = extractMcpToolsFromProcedures(mcpRouter).tools.find(
|
||||
const tool = extractMcpToolsFromProcedures(mcpRouter).find(
|
||||
(candidate) => candidate.name === "customWidget_validateTemplate",
|
||||
);
|
||||
|
||||
@@ -359,119 +355,3 @@ 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,6 +37,12 @@ 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();
|
||||
@@ -44,7 +50,36 @@ 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(
|
||||
@@ -72,6 +107,8 @@ 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),
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
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);
|
||||
}
|
||||
});
|
||||
});
|
||||
Generated
-9
@@ -366,9 +366,6 @@ 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
|
||||
@@ -1523,9 +1520,6 @@ 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)
|
||||
@@ -1541,9 +1535,6 @@ 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
|
||||
|
||||
@@ -123,7 +123,6 @@ 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
|
||||
|
||||
@@ -2,20 +2,6 @@ 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: {
|
||||
@@ -47,7 +33,6 @@ export default defineConfig({
|
||||
environment: "node",
|
||||
setupFiles: ["./vitest.setup.ts", "./vitest.setup.node.ts"],
|
||||
include: ["packages/db/test/**/*.spec.ts"],
|
||||
exclude: [...configDefaults.exclude, ...integrationTests],
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -92,34 +77,15 @@ 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"],
|
||||
},
|
||||
},
|
||||
],
|
||||
|
||||
Reference in new issue
Block a user