Merge refreshed widget modernization into mobile boards
This commit is contained in:
23 files changed
+336
-17
No files matched your search
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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,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>
|
||||
);
|
||||
}
|
||||
@@ -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
@@ -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;
|
||||
});
|
||||
|
||||
|
||||
@@ -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=
|
||||
@@ -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
@@ -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:
|
||||
|
||||
|
||||
@@ -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:
|
||||
@@ -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:-}
|
||||
|
||||
@@ -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:-*}" \
|
||||
|
||||
@@ -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
@@ -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
@@ -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,
|
||||
|
||||
@@ -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 --"
|
||||
|
||||
Reference in new issue
Block a user