Merge refreshed dnd kit grid into widget modernization

This commit is contained in:
Thomas Camlong committed 2026-08-09 22:00:05 +02:00
commit a547e29607
23 files changed
+336 -17

No files matched your search

+2 -1
View File
@@ -23,8 +23,9 @@ jobs:
- 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: docker build --target production -f apps/workshop/Dockerfile .
- run: pnpm test:workshop-image
publish:
if: github.event_name == 'push' && (github.ref_name == 'v2' || github.ref_name == 'main')
@@ -112,8 +112,8 @@ The advanced deployments environment variables should only be used if you know w
| Environment Variable | Description |
| ------------------------ | ----------------------------------------------------------------------------------------------------------------------------------- |
| `DB_MIGRATIONS_DISABLED` | Disable db migrations. For example for helm charts. |
| `WORKSHOP_PUBLIC_ORIGIN` | Origin used in PocketBase-generated links and email. It must be set explicitly in production. |
| `PB_ALLOWED_ORIGINS` | Browser origins accepted by PocketBase. The central public community defaults to `*` so self-hosted Homarr instances can use OAuth. |
| `WORKSHOP_PUBLIC_ORIGIN` | Origin used in PocketBase-generated links and email. It must be set explicitly in production and must match the externally reachable Workshop origin. |
| `PB_ALLOWED_ORIGINS` | Browser origins accepted by PocketBase. Use `*` for the central public community so arbitrary self-hosted Homarr instances can use the API and OAuth; private staging may use a comma-separated origin allowlist. |
All public URL variables reject non-HTTP protocols, embedded credentials, query strings, and fragments, and normalize
trailing slashes when Homarr or Docusaurus starts. The Docusaurus-only `WORKSHOP_URL` name is accepted as a deprecated alias for
+39 -2
View File
@@ -108,8 +108,45 @@ The public URL contract is:
| `WORKSHOP_PUBLIC_ORIGIN` | PocketBase-generated links and email | Required in production |
| `PB_ALLOWED_ORIGINS` | PocketBase browser origins | `*` for the central community |
The deprecated docs-only `WORKSHOP_URL` alias remains for one release; `WORKSHOP_API_URL` takes precedence. Restart
services after changing these values.
The deprecated docs-only `WORKSHOP_URL` alias remains for one release; `WORKSHOP_API_URL` takes precedence. The
combined Workshop image writes these public URLs into its browser runtime configuration whenever the container starts;
the Docker build arguments remain fallbacks. Restart the container after changing these values, without rebuilding it.
### Staging and demo deployment
Use `apps/workshop/docker-compose.staging.yml` with the published `ghcr.io/homarr-labs/workshop:v2` image for a v2
preview. Copy `apps/workshop/.env.staging.example` to `.env.staging`; its default public contract is:
```dotenv
HOMARR_WEBSITE_URL=https://v2.preview.homarr.dev
WORKSHOP_API_URL=https://v2.preview.homarr.dev
WORKSHOP_WEB_URL=https://v2.preview.homarr.dev/workshop
WORKSHOP_PUBLIC_ORIGIN=https://v2.preview.homarr.dev
PB_ALLOWED_ORIGINS=*
```
Start or update it from the repository root:
```sh
docker compose --env-file apps/workshop/.env.staging \
-f apps/workshop/docker-compose.staging.yml pull
docker compose --env-file apps/workshop/.env.staging \
-f apps/workshop/docker-compose.staging.yml up --detach
curl --fail http://127.0.0.1:8090/api/health
```
The service binds to `127.0.0.1:8090`. Terminate TLS at a reverse proxy and forward the **entire hostname**, including
`/api`, `/_/`, and `/workshop-runtime-config.js`; forwarding only `/workshop` breaks PocketBase and runtime URL loading.
This combined image serves both the documentation site and PocketBase, so use a dedicated Workshop hostname if the
chosen hostname already serves Homarr itself. In that case, replace the hostname in all four URL values and keep
`/workshop` only on `WORKSHOP_WEB_URL`.
The Compose volume preserves PocketBase data across pulls and container recreation. The mutable `v2` tag is suitable
for previews; use the `sha-<commit>` tag for promotion. For the public central Workshop, `PB_ALLOWED_ORIGINS=*` allows
self-hosted Homarr origins to use the API and OAuth. A private demo may use a comma-separated origin allowlist instead.
GitHub OAuth requires both credentials and the callback
`https://v2.preview.homarr.dev/api/oauth2-redirect`. Restart after changing environment values, then verify the health
endpoint, `/workshop`, a public listing request, GitHub sign-in, and an installation from the Homarr preview.
Back up application and PocketBase data before deployment. Publish the combined image by immutable digest, test the
exact digest in staging, rehearse application rollback and PocketBase restore, then promote that same digest.
+3
View File
@@ -12,6 +12,7 @@ if (deprecatedWorkshopUrl && !process.env.WORKSHOP_API_URL) {
const publicUrls = resolveHomarrUrlConfig({
homarrWebsiteUrl: process.env.HOMARR_WEBSITE_URL,
workshopApiUrl: process.env.WORKSHOP_API_URL ?? deprecatedWorkshopUrl,
workshopWebUrl: process.env.WORKSHOP_WEB_URL,
});
const config: Config = {
title: "Homarr documentation",
@@ -24,7 +25,9 @@ const config: Config = {
projectName: "homarr",
customFields: {
workshopUrl: publicUrls.workshopApiUrl,
workshopWebUrl: publicUrls.workshopWebUrl,
},
scripts: [{ src: "/workshop-runtime-config.js" }],
i18n: {
defaultLocale: "en",
locales: ["en"],
@@ -29,6 +29,7 @@ import { toast } from "sonner";
import type { WorkshopSubmission, WorkshopVote } from "@site/src/lib/pocketbase";
import { getWorkshopBackend } from "@site/src/lib/pocketbase";
import { getRuntimeWorkshopApiUrl } from "@site/src/lib/runtime-config";
import type { SubmissionType } from "@site/src/lib/workshop-schema";
import type { HomarrCustomWidgetV2 } from "@homarr/custom-widgets/core";
import {
@@ -1028,7 +1029,7 @@ export default function MarketplaceDetailPage() {
<BrowserOnly fallback={<DetailSkeleton />}>
{() => (
<WorkshopErrorBoundary>
<MarketplaceDetail workshopUrl={configuredWorkshopUrl || window.location.origin} />
<MarketplaceDetail workshopUrl={getRuntimeWorkshopApiUrl(configuredWorkshopUrl)} />
</WorkshopErrorBoundary>
)}
</BrowserOnly>
+19
View File
@@ -0,0 +1,19 @@
import { afterEach, describe, expect, test } from "vitest";
import { getRuntimeWorkshopApiUrl } from "./runtime-config";
describe("Workshop runtime configuration", () => {
afterEach(() => {
delete window.homarrRuntimeConfig;
});
test("prefers the URL supplied when the container starts", () => {
window.homarrRuntimeConfig = { workshopApiUrl: "https://v2.preview.homarr.dev" };
expect(getRuntimeWorkshopApiUrl("https://homarr.dev")).toBe("https://v2.preview.homarr.dev");
});
test("keeps the build-time URL as a fallback", () => {
expect(getRuntimeWorkshopApiUrl("https://homarr.dev")).toBe("https://homarr.dev");
});
});
+16
View File
@@ -0,0 +1,16 @@
interface HomarrRuntimeConfig {
homarrWebsiteUrl?: string;
workshopApiUrl?: string;
workshopWebUrl?: string;
}
declare global {
interface Window {
homarrRuntimeConfig?: Readonly<HomarrRuntimeConfig>;
}
}
export const getRuntimeWorkshopApiUrl = (configuredUrl: string): string => {
if (typeof window === "undefined") return configuredUrl;
return window.homarrRuntimeConfig?.workshopApiUrl || configuredUrl || window.location.origin;
};
+3 -1
View File
@@ -3,12 +3,14 @@ import useDocusaurusContext from "@docusaurus/useDocusaurusContext";
import Layout from "@theme/Layout";
import { WorkshopAdmin } from "@site/src/components/workshop/WorkshopAdmin";
import { getRuntimeWorkshopApiUrl } from "@site/src/lib/runtime-config";
export default function WorkshopAdminRoute() {
const { siteConfig } = useDocusaurusContext();
const workshopUrl = (siteConfig.customFields?.workshopUrl as string | undefined) ?? "";
return (
<Layout title="Workshop moderation" noFooter>
<WorkshopAdmin workshopUrl={workshopUrl} />
<WorkshopAdmin workshopUrl={getRuntimeWorkshopApiUrl(workshopUrl)} />
</Layout>
);
}
+2 -1
View File
@@ -5,6 +5,7 @@ import Layout from "@theme/Layout";
import { WorkshopApp, WorkshopListingFallback } from "@site/src/components/workshop/WorkshopApp";
import { WorkshopErrorBoundary } from "@site/src/components/workshop/WorkshopErrorBoundary";
import { getRuntimeWorkshopApiUrl } from "@site/src/lib/runtime-config";
export default function WorkshopPage() {
const { siteConfig } = useDocusaurusContext();
@@ -16,7 +17,7 @@ export default function WorkshopPage() {
<BrowserOnly fallback={<WorkshopListingFallback />}>
{() => (
<WorkshopErrorBoundary>
<WorkshopApp workshopUrl={configuredWorkshopUrl || window.location.origin} />
<WorkshopApp workshopUrl={getRuntimeWorkshopApiUrl(configuredWorkshopUrl)} />
</WorkshopErrorBoundary>
)}
</BrowserOnly>
+1
View File
@@ -0,0 +1 @@
globalThis.homarrRuntimeConfig ??= Object.freeze({});
@@ -1324,7 +1324,7 @@ const configureEditorPlugins = (defaults: Plugins<DragDropManager>) =>
dropAnimation: null,
keyboardTransition: { duration: 100 },
});
if (plugin === AutoScroller) return AutoScroller.configure({ acceleration: 24, threshold: { x: 0.16, y: 0.16 } });
if (plugin === AutoScroller) return AutoScroller.configure({ acceleration: 24, threshold: { x: 0, y: 0.16 } });
return plugin;
});
+12
View File
@@ -0,0 +1,12 @@
WORKSHOP_IMAGE=ghcr.io/homarr-labs/workshop:v2
WORKSHOP_EXPOSE_PORT=8090
HOMARR_WEBSITE_URL=https://v2.preview.homarr.dev
WORKSHOP_API_URL=https://v2.preview.homarr.dev
WORKSHOP_WEB_URL=https://v2.preview.homarr.dev/workshop
WORKSHOP_PUBLIC_ORIGIN=https://v2.preview.homarr.dev
PB_ALLOWED_ORIGINS=*
# Optional GitHub OAuth application for the preview hostname.
# GITHUB_CLIENT_ID=
# GITHUB_CLIENT_SECRET=
+9 -1
View File
@@ -18,15 +18,17 @@ RUN --mount=type=cache,id=workshop-pnpm-store,target=/pnpm/store pnpm config set
FROM dependencies AS website
ARG HOMARR_WEBSITE_URL=https://homarr.dev
ARG WORKSHOP_API_URL=https://homarr.dev
ARG WORKSHOP_WEB_URL=https://homarr.dev/workshop
ENV HOMARR_WEBSITE_URL=$HOMARR_WEBSITE_URL
ENV WORKSHOP_API_URL=$WORKSHOP_API_URL
ENV WORKSHOP_WEB_URL=$WORKSHOP_WEB_URL
RUN CI=true SKIP_ENV_VALIDATION=true pnpm turbo build --filter=@homarr/docs
FROM ${POCKETBASE_IMAGE} AS pocketbase-runtime
ENV PORT=8090
RUN addgroup -S pocketbase && adduser -S -G pocketbase pocketbase
COPY apps/workshop/entrypoint.sh /usr/local/bin/workshop-entrypoint
RUN chmod 755 /usr/local/bin/workshop-entrypoint && mkdir -p /pb_data /pb_public && chown pocketbase:pocketbase /pb_data
RUN chmod 755 /usr/local/bin/workshop-entrypoint && mkdir -p /pb_data /pb_public && chown pocketbase:pocketbase /pb_data /pb_public
ENTRYPOINT ["workshop-entrypoint"]
FROM pocketbase-runtime AS development
@@ -34,6 +36,12 @@ EXPOSE 8090
HEALTHCHECK --interval=10s --timeout=5s --start-period=15s --retries=6 CMD wget -q --spider "http://127.0.0.1:${PORT}/api/health" || exit 1
FROM pocketbase-runtime AS production
ARG HOMARR_WEBSITE_URL=https://homarr.dev
ARG WORKSHOP_API_URL=https://homarr.dev
ARG WORKSHOP_WEB_URL=https://homarr.dev/workshop
ENV HOMARR_WEBSITE_URL=$HOMARR_WEBSITE_URL
ENV WORKSHOP_API_URL=$WORKSHOP_API_URL
ENV WORKSHOP_WEB_URL=$WORKSHOP_WEB_URL
COPY --from=website /app/apps/docs/build /pb_public
COPY apps/workshop/pb_migrations /pb_migrations
COPY apps/workshop/pb_hooks /pb_hooks
+45 -1
View File
@@ -70,7 +70,8 @@ pnpm dev
`WORKSHOP_API_URL` controls API calls. `WORKSHOP_WEB_URL` controls links from Homarr. `HOMARR_WEBSITE_URL` is the
documentation/site base. All three must be absolute HTTP(S) URLs. The old Docusaurus-only `WORKSHOP_URL` variable
remains a deprecated alias for one release.
remains a deprecated alias for one release. The production image writes these values into the browser runtime
configuration at container startup, so changing them requires a restart but not an image rebuild.
## Optional GitHub OAuth
@@ -161,6 +162,45 @@ WORKSHOP_PUBLIC_ORIGIN=https://homarr.dev
PB_ALLOWED_ORIGINS=*
```
### Deploy a v2 staging or demo instance
The staging Compose file runs the published `ghcr.io/homarr-labs/workshop:v2` image, persists `/pb_data`, binds only to
the host loopback interface, and expects TLS to terminate at a reverse proxy. It does not need a repository checkout to
run; copy `docker-compose.staging.yml` and `.env.staging.example` to the deployment host, then:
```sh
cp .env.staging.example .env.staging
docker compose --env-file .env.staging -f docker-compose.staging.yml pull
docker compose --env-file .env.staging -f docker-compose.staging.yml up --detach
docker compose --env-file .env.staging -f docker-compose.staging.yml ps
curl --fail http://127.0.0.1:8090/api/health
```
The checked-in example is ready for `https://v2.preview.homarr.dev`:
```dotenv
HOMARR_WEBSITE_URL=https://v2.preview.homarr.dev
WORKSHOP_API_URL=https://v2.preview.homarr.dev
WORKSHOP_WEB_URL=https://v2.preview.homarr.dev/workshop
WORKSHOP_PUBLIC_ORIGIN=https://v2.preview.homarr.dev
PB_ALLOWED_ORIGINS=*
```
Proxy the entire `v2.preview.homarr.dev` hostname to `http://127.0.0.1:8090`, including `/api`, `/_/`, and
`/workshop-runtime-config.js`; do not proxy only `/workshop`. The combined image owns both the Docusaurus pages and
PocketBase routes. If the hostname already serves the Homarr application, use a dedicated Workshop hostname instead
and set all four public URL variables to that hostname (with `/workshop` only on `WORKSHOP_WEB_URL`).
The `v2` image tag is intentionally convenient and mutable for previews. Run `pull` followed by `up --detach` to test a
new v2 image while retaining the named PocketBase volume. Use the immutable `sha-<commit>` image tag when promoting a
tested build. The public central Workshop needs `PB_ALLOWED_ORIGINS=*` because Homarr instances on arbitrary origins
call the API and open the OAuth flow; a private demo can instead use a comma-separated allowlist of its browser origins.
After startup, open `https://v2.preview.homarr.dev/_/` to create the first PocketBase superuser. If GitHub OAuth is
enabled, configure its callback as `https://v2.preview.homarr.dev/api/oauth2-redirect`, set both GitHub credentials, and
restart the service. Verify `/workshop`, `/api/health`, listing requests, sign-in, and one installation from the Homarr
preview before sharing the demo.
Build and smoke-test the combined image locally:
```sh
@@ -171,6 +211,10 @@ curl --fail http://127.0.0.1:3003/workshop
docker compose -f apps/workshop/docker-compose.yml --profile docs stop docs
```
`pnpm test:workshop-image` builds the production target, starts it with the preview URL contract, and proves that the
browser runtime configuration, Workshop page, health endpoint, migrations, public listing endpoint, and cross-origin
browser access all work.
Normal `up`, `start`, `stop`, and `down` operations retain the named volume. The following command is an intentional,
irreversible local reset:
+25
View File
@@ -0,0 +1,25 @@
name: homarr-workshop-staging
services:
workshop:
image: ${WORKSHOP_IMAGE:-ghcr.io/homarr-labs/workshop:v2}
restart: unless-stopped
environment:
HOMARR_WEBSITE_URL: ${HOMARR_WEBSITE_URL:?Set HOMARR_WEBSITE_URL in the Workshop environment file}
PB_ALLOWED_ORIGINS: ${PB_ALLOWED_ORIGINS:-*}
PORT: 8090
WORKSHOP_API_URL: ${WORKSHOP_API_URL:?Set WORKSHOP_API_URL in the Workshop environment file}
WORKSHOP_PUBLIC_ORIGIN: ${WORKSHOP_PUBLIC_ORIGIN:?Set WORKSHOP_PUBLIC_ORIGIN in the Workshop environment file}
WORKSHOP_WEB_URL: ${WORKSHOP_WEB_URL:?Set WORKSHOP_WEB_URL in the Workshop environment file}
GITHUB_CLIENT_ID: ${GITHUB_CLIENT_ID:-}
GITHUB_CLIENT_SECRET: ${GITHUB_CLIENT_SECRET:-}
ports:
- "127.0.0.1:${WORKSHOP_EXPOSE_PORT:-8090}:8090"
volumes:
- pb_data:/pb_data
security_opt:
- no-new-privileges:true
stop_grace_period: 30s
volumes:
pb_data:
+5
View File
@@ -8,8 +8,10 @@ services:
target: development
restart: unless-stopped
environment:
HOMARR_WEBSITE_URL: ${HOMARR_WEBSITE_URL:-http://localhost:3003}
PB_ALLOWED_ORIGINS: ${PB_ALLOWED_ORIGINS:-*}
PORT: ${PB_EXPOSE_PORT:-8090}
WORKSHOP_API_URL: ${WORKSHOP_API_URL:-http://localhost:8090}
WORKSHOP_WEB_URL: ${WORKSHOP_WEB_URL:-http://localhost:3003/workshop}
GITHUB_CLIENT_ID: ${GITHUB_CLIENT_ID:-}
GITHUB_CLIENT_SECRET: ${GITHUB_CLIENT_SECRET:-}
@@ -33,9 +35,12 @@ services:
args:
HOMARR_WEBSITE_URL: ${HOMARR_WEBSITE_URL:-http://localhost:3003}
WORKSHOP_API_URL: ${WORKSHOP_API_URL:-http://localhost:3003}
WORKSHOP_WEB_URL: ${WORKSHOP_WEB_URL:-http://localhost:3003/workshop}
environment:
HOMARR_WEBSITE_URL: ${HOMARR_WEBSITE_URL:-http://localhost:3003}
PB_ALLOWED_ORIGINS: ${PB_ALLOWED_ORIGINS:-*}
PORT: ${DOCS_EXPOSE_PORT:-3003}
WORKSHOP_API_URL: ${WORKSHOP_API_URL:-http://localhost:3003}
WORKSHOP_WEB_URL: ${WORKSHOP_WEB_URL:-http://localhost:3003/workshop}
GITHUB_CLIENT_ID: ${GITHUB_CLIENT_ID:-}
GITHUB_CLIENT_SECRET: ${GITHUB_CLIENT_SECRET:-}
+18
View File
@@ -6,6 +6,24 @@ if [ "$(id -u)" -eq 0 ]; then
exec su -p -s /bin/sh pocketbase -c 'exec /usr/local/bin/workshop-entrypoint'
fi
HOMARR_WEBSITE_URL=${HOMARR_WEBSITE_URL:-https://homarr.dev}
WORKSHOP_API_URL=${WORKSHOP_API_URL:-$HOMARR_WEBSITE_URL}
WORKSHOP_WEB_URL=${WORKSHOP_WEB_URL:-$HOMARR_WEBSITE_URL/workshop}
encode_runtime_value() {
printf '%s' "$1" | base64 | tr -d '\n'
}
cat >/pb_public/workshop-runtime-config.js <<EOF
const decodeRuntimeValue = (value) =>
new TextDecoder().decode(Uint8Array.from(atob(value), (character) => character.charCodeAt(0)));
globalThis.homarrRuntimeConfig = Object.freeze({
homarrWebsiteUrl: decodeRuntimeValue("$(encode_runtime_value "$HOMARR_WEBSITE_URL")"),
workshopApiUrl: decodeRuntimeValue("$(encode_runtime_value "$WORKSHOP_API_URL")"),
workshopWebUrl: decodeRuntimeValue("$(encode_runtime_value "$WORKSHOP_WEB_URL")")
});
EOF
exec pocketbase serve \
--http="0.0.0.0:${PORT:-8090}" \
--origins="${PB_ALLOWED_ORIGINS:-*}" \
+50
View File
@@ -0,0 +1,50 @@
#!/bin/sh
set -eu
WORKSHOP_IMAGE_TEST_PORT=${WORKSHOP_IMAGE_TEST_PORT:-18091}
WORKSHOP_IMAGE_TEST_NAME="homarr-workshop-image-test-$$"
WORKSHOP_IMAGE_TEST_TAG="homarr-workshop:test-$$"
PREVIEW_ORIGIN=https://v2.preview.homarr.dev
cleanup() {
docker rm --force "$WORKSHOP_IMAGE_TEST_NAME" >/dev/null 2>&1 || true
}
trap cleanup EXIT
docker build --target production -f apps/workshop/Dockerfile -t "$WORKSHOP_IMAGE_TEST_TAG" .
docker run --detach --name "$WORKSHOP_IMAGE_TEST_NAME" \
--publish "127.0.0.1:$WORKSHOP_IMAGE_TEST_PORT:8090" \
--env HOMARR_WEBSITE_URL="$PREVIEW_ORIGIN" \
--env WORKSHOP_API_URL="$PREVIEW_ORIGIN" \
--env WORKSHOP_WEB_URL="$PREVIEW_ORIGIN/workshop" \
--env WORKSHOP_PUBLIC_ORIGIN="$PREVIEW_ORIGIN" \
--env PB_ALLOWED_ORIGINS='*' \
"$WORKSHOP_IMAGE_TEST_TAG" >/dev/null
for attempt in $(seq 1 60); do
if curl --fail --silent "http://127.0.0.1:$WORKSHOP_IMAGE_TEST_PORT/api/health" >/dev/null; then
break
fi
if [ "$attempt" -eq 60 ]; then
docker logs "$WORKSHOP_IMAGE_TEST_NAME"
exit 1
fi
sleep 1
done
WORKSHOP_TEST_URL="http://127.0.0.1:$WORKSHOP_IMAGE_TEST_PORT" \
EXPECTED_HOMARR_WEBSITE_URL="$PREVIEW_ORIGIN" \
EXPECTED_WORKSHOP_API_URL="$PREVIEW_ORIGIN" \
EXPECTED_WORKSHOP_WEB_URL="$PREVIEW_ORIGIN/workshop" \
node apps/workshop/tests/runtime-config.integration.mjs
curl --fail --location --silent "http://127.0.0.1:$WORKSHOP_IMAGE_TEST_PORT/workshop" >/dev/null
curl --fail --silent \
"http://127.0.0.1:$WORKSHOP_IMAGE_TEST_PORT/api/collections/workshop_listings/records?perPage=1" >/dev/null
curl --fail --silent --dump-header - --output /dev/null \
--header 'Origin: https://homarr.example' \
"http://127.0.0.1:$WORKSHOP_IMAGE_TEST_PORT/api/collections/workshop_listings/records?perPage=1" \
| tr -d '\r' \
| grep -qi '^Access-Control-Allow-Origin: \*$'
echo "Workshop production image passed"
+10 -1
View File
@@ -14,7 +14,10 @@ cleanup() {
}
trap cleanup EXIT
PB_EXPOSE_PORT="$WORKSHOP_TEST_PORT" docker compose -p "$WORKSHOP_TEST_PROJECT" \
HOMARR_WEBSITE_URL=https://preview.example.invalid \
WORKSHOP_API_URL=https://api.preview.example.invalid \
WORKSHOP_WEB_URL=https://preview.example.invalid/workshop \
PB_EXPOSE_PORT="$WORKSHOP_TEST_PORT" docker compose -p "$WORKSHOP_TEST_PROJECT" \
-f apps/workshop/docker-compose.yml up --build -d
for attempt in $(seq 1 60); do
@@ -29,6 +32,12 @@ for attempt in $(seq 1 60); do
sleep 1
done
WORKSHOP_TEST_URL="http://127.0.0.1:$WORKSHOP_TEST_PORT" \
EXPECTED_HOMARR_WEBSITE_URL=https://preview.example.invalid \
EXPECTED_WORKSHOP_API_URL=https://api.preview.example.invalid \
EXPECTED_WORKSHOP_WEB_URL=https://preview.example.invalid/workshop \
node apps/workshop/tests/runtime-config.integration.mjs
docker compose -p "$WORKSHOP_TEST_PROJECT" -f apps/workshop/docker-compose.yml exec -T workshop \
pocketbase superuser create \
workshop-test@example.invalid 'WorkshopLocalTest123!' --dir=/pb_data
@@ -0,0 +1,24 @@
import vm from "node:vm";
const baseUrl = process.env.WORKSHOP_TEST_URL;
if (!baseUrl) throw new Error("WORKSHOP_TEST_URL is required");
const response = await fetch(`${baseUrl}/workshop-runtime-config.js`);
if (!response.ok) throw new Error(`Runtime configuration returned ${response.status}`);
const context = { TextDecoder, Uint8Array, atob };
context.globalThis = context;
vm.runInNewContext(await response.text(), context);
const actual = JSON.parse(JSON.stringify(context.homarrRuntimeConfig));
const expected = {
homarrWebsiteUrl: process.env.EXPECTED_HOMARR_WEBSITE_URL,
workshopApiUrl: process.env.EXPECTED_WORKSHOP_API_URL,
workshopWebUrl: process.env.EXPECTED_WORKSHOP_WEB_URL,
};
if (JSON.stringify(actual) !== JSON.stringify(expected)) {
throw new Error(`Unexpected Workshop runtime configuration: ${JSON.stringify(actual)}`);
}
console.log("Workshop runtime configuration passed");
@@ -54,4 +54,15 @@ for (const required of [
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");
}
await access(resolve("apps/docs/static/workshop-runtime-config.js"));
console.log("Workshop static contracts passed");
+36 -5
View File
@@ -253,6 +253,38 @@ describe("Board grid", () => {
expect(await rail.evaluate((element) => getComputedStyle(element, "::before").content.replaceAll('"', ""))).toBe(
"Left dashboard rail",
);
const railGrid = rail.locator("[data-grid-section-id]").first();
const railSnapshot = await readEditorGridSnapshotAsync(railGrid);
await rail.evaluate((element) => {
const overflowFixture = document.createElement("div");
overflowFixture.dataset.e2eRailOverflowFixture = "true";
overflowFixture.style.position = "absolute";
overflowFixture.style.insetInlineStart = "100%";
overflowFixture.style.width = "500px";
overflowFixture.style.height = "1px";
overflowFixture.style.pointerEvents = "none";
element.appendChild(overflowFixture);
});
await expect
.poll(async () => await rail.evaluate((element) => element.scrollWidth - element.clientWidth))
.toBeGreaterThan(0);
const railItemBox = await expectBoundingBoxAsync(railItem);
const railDragStart = {
x: railItemBox.x + railItemBox.width / 2,
y: railItemBox.y + railItemBox.height / 2,
};
await page.mouse.move(railDragStart.x, railDragStart.y);
await page.mouse.down();
await page.mouse.move(railDragStart.x + 8, railDragStart.y);
await expect(railItem).toHaveAttribute("data-dnd-drag-source", "true");
await page.mouse.move(railBox.x + railBox.width - 2, railDragStart.y, { steps: 12 });
await page.waitForTimeout(250);
expect(await rail.evaluate((element) => element.scrollLeft)).toBe(0);
expect((await expectBoundingBoxAsync(rail)).x).toBeCloseTo(railBox.x, 1);
await page.keyboard.press("Escape");
await page.mouse.up();
await expect.poll(async () => await readEditorGridSnapshotAsync(railGrid)).toEqual(railSnapshot);
await rail.locator('[data-e2e-rail-overflow-fixture="true"]').evaluate((element) => element.remove());
await expect(logicalTile).toHaveCSS("overflow-x", "hidden");
await expect(logicalTile).toHaveCSS("overflow-y", "auto");
await logicalTile.evaluate((element) => {
@@ -566,9 +598,8 @@ describe("Board grid", () => {
});
const containerGrip = containerSection.locator("[data-grid-container-drag-handle]");
await expectTargetSizeAsync(containerGrip);
const railEditor = rail.locator("[data-grid-section-id]").first();
const railGridBox = await expectBoundingBoxAsync(railEditor);
await dragHandleToGridPositionAsync(page, containerGrip, containerSection, railEditor, 0, 0, canvasScale);
const railGridBox = await expectBoundingBoxAsync(railGrid);
await dragHandleToGridPositionAsync(page, containerGrip, containerSection, railGrid, 0, 0, canvasScale);
await expect(containerSection.locator("xpath=ancestor::aside[1]")).toHaveAttribute("data-board-gutter", "left");
await expect(secondItem.locator("xpath=ancestor::*[@data-grid-section-id][1]")).toHaveAttribute(
"data-grid-section-id",
@@ -677,7 +708,7 @@ describe("Board grid", () => {
await railKeyboardEntry.focus();
await railKeyboardEntry.press("Enter");
const mixedInputSnapshot = await readEditorGridSnapshotAsync(mainGrid);
const mixedInputRailSnapshot = await readEditorGridSnapshotAsync(railEditor);
const mixedInputRailSnapshot = await readEditorGridSnapshotAsync(railGrid);
const mixedInputHandleBox = await expectBoundingBoxAsync(eastResizeHandle);
await page.mouse.move(
mixedInputHandleBox.x + mixedInputHandleBox.width / 2,
@@ -690,7 +721,7 @@ describe("Board grid", () => {
await page.keyboard.press("Escape");
await page.mouse.up();
await expect.poll(async () => await readEditorGridSnapshotAsync(mainGrid)).toEqual(mixedInputSnapshot);
await expect.poll(async () => await readEditorGridSnapshotAsync(railEditor)).toEqual(mixedInputRailSnapshot);
await expect.poll(async () => await readEditorGridSnapshotAsync(railGrid)).toEqual(mixedInputRailSnapshot);
await expectTouchResizeCancellationRestoresAsync(
page,
mainGrid,
+1
View File
@@ -49,6 +49,7 @@
"test": "cross-env NODE_ENV=development CI=true vitest run --project \"!e2e\" --coverage.enabled ",
"test:e2e": "cross-env NODE_ENV=development CI=true vitest e2e",
"test:workshop": "sh apps/workshop/test.sh",
"test:workshop-image": "sh apps/workshop/test-image.sh",
"test:ui": "cross-env NODE_ENV=development CI=true vitest --project \"!e2e\" --ui --coverage.enabled",
"typecheck": "turbo typecheck",
"with-env": "dotenv -e .env --"