ci: remove automatic test gates and trim test suites

This commit is contained in:
ajnart committed 2026-09-10 19:08:37 +02:00
1 parent 3358b6337f
commit 2501fa59dd
20 files changed
+164 -820

No files matched your search

+10 -57
View File
@@ -97,17 +97,16 @@ jobs:
- name: Validate Custom Widget bundle budget
run: |
pnpm --filter nextjs build
pnpm turbo run build --filter=@homarr/nextjs
pnpm check:custom-widget-bundle
- name: Run unit tests
run: pnpm test
container:
name: Container and E2E
name: Container build
# Same-repository previews already build amd64; only build a fallback when no digest is available.
needs: [fast-gate, preview-amd64]
if: |
!cancelled() && needs.fast-gate.result == 'success' &&
needs.preview-amd64.result != 'failure' && needs.preview-amd64.outputs.digest == '' &&
(
github.event_name == 'merge_group' ||
(github.event_name == 'push' && github.ref == 'refs/heads/release/v2') ||
@@ -115,10 +114,9 @@ jobs:
(github.event_name == 'workflow_dispatch' && (inputs.operation == 'validate' || inputs.operation == 'publish'))
)
runs-on: ubuntu-latest
timeout-minutes: 30
timeout-minutes: 20
permissions:
contents: read
packages: read
steps:
- uses: actions/checkout@v7
if: "!env.ACT"
@@ -126,23 +124,10 @@ jobs:
persist-credentials: false
ref: ${{ github.event_name == 'workflow_dispatch' && inputs.ref || '' }}
- name: Setup
uses: ./tooling/github/setup
- name: Set up Docker Buildx
if: "!env.ACT"
if: needs.preview-amd64.outputs.digest == '' && !env.ACT
uses: docker/setup-buildx-action@v4
- name: Reuse amd64 preview image
if: needs.preview-amd64.outputs.digest != '' && !env.ACT
env:
DIGEST: ${{ needs.preview-amd64.outputs.digest }}
run: |
echo "${{ secrets.GITHUB_TOKEN }}" | docker login ghcr.io -u "${{ github.actor }}" --password-stdin
docker pull "${PREVIEW_IMAGE}@${DIGEST}"
docker tag "${PREVIEW_IMAGE}@${DIGEST}" homarr-ci
docker tag "${PREVIEW_IMAGE}@${DIGEST}" homarr-e2e
- name: Build container once
if: needs.preview-amd64.outputs.digest == '' && !env.ACT
uses: docker/build-push-action@v7
@@ -152,9 +137,7 @@ jobs:
platforms: linux/amd64
load: true
push: false
tags: |
homarr-ci
homarr-e2e
tags: homarr-ci
labels: |
org.opencontainers.image.source=${{ github.server_url }}/${{ github.repository }}
org.opencontainers.image.revision=${{ github.sha }}
@@ -182,37 +165,7 @@ jobs:
fi
done
DOCKER_BUILDKIT=1 docker build --platform linux/amd64 \
--tag homarr-ci --tag homarr-e2e "${secret_args[@]}" .
- name: Cache Playwright browsers
if: "!env.ACT"
id: playwright-cache
uses: actions/cache@v6
with:
path: ~/.cache/ms-playwright
key: playwright-${{ runner.os }}-${{ hashFiles('pnpm-lock.yaml') }}
- name: Install Playwright Chromium
if: steps.playwright-cache.outputs.cache-hit != 'true' || env.ACT
run: pnpm exec playwright install --with-deps chromium
- name: Install Playwright system deps
if: steps.playwright-cache.outputs.cache-hit == 'true'
run: pnpm exec playwright install-deps chromium
- name: Run E2E tests
env:
NODE_ENV: development
CI: "true"
run: pnpm exec vitest e2e --retry 1 --maxWorkers 4
- name: Upload E2E artifacts
if: failure() && !env.ACT
uses: actions/upload-artifact@v7
with:
name: playwright-report
path: e2e/test-results/
retention-days: 7
--tag homarr-ci "${secret_args[@]}" .
preview-amd64:
name: Build amd64 preview image
@@ -320,10 +273,10 @@ jobs:
publish-preview:
name: Publish multi-platform preview image
needs: [container, preview-amd64, preview-arm64]
needs: [fast-gate, preview-amd64, preview-arm64]
if: |
!cancelled() &&
needs.container.result == 'success' &&
needs.fast-gate.result == 'success' &&
needs.preview-amd64.result == 'success' &&
needs.preview-arm64.result == 'success' &&
(
+29 -12
View File
@@ -2,8 +2,36 @@ name: Workshop
on:
pull_request:
paths:
- ".github/workflows/workshop.yml"
- "apps/workshop/**"
- "apps/docs/**"
- "packages/custom-widgets/**"
- "packages/definitions/**"
- "packages/workshop/**"
- "tooling/typescript/**"
- "patches/**"
- "package.json"
- "pnpm-lock.yaml"
- "pnpm-workspace.yaml"
- "turbo.json"
- ".npmrc"
push:
branches: [dev, v2, release/v2, main]
paths:
- ".github/workflows/workshop.yml"
- "apps/workshop/**"
- "apps/docs/**"
- "packages/custom-widgets/**"
- "packages/definitions/**"
- "packages/workshop/**"
- "tooling/typescript/**"
- "patches/**"
- "package.json"
- "pnpm-lock.yaml"
- "pnpm-workspace.yaml"
- "turbo.json"
- ".npmrc"
permissions:
contents: read
@@ -15,21 +43,10 @@ concurrency:
jobs:
validate:
runs-on: ubuntu-24.04
timeout-minutes: 30
timeout-minutes: 5
steps:
- uses: actions/checkout@v7
- uses: actions/setup-node@v6
with:
node-version: 24.18.0
- uses: pnpm/action-setup@v6
with:
run_install: false
- run: pnpm install --frozen-lockfile
- run: pnpm --filter @homarr/workshop typecheck
- run: pnpm --filter @homarr/docs typecheck
- run: docker compose --env-file apps/workshop/.env.staging.example -f apps/workshop/docker-compose.staging.yml config --quiet
- run: pnpm test:workshop
- run: pnpm test:workshop-image
publish-amd64:
if: github.event_name == 'push' && (github.ref_name == 'v2' || github.ref_name == 'release/v2' || github.ref_name == 'main')
@@ -82,3 +82,25 @@ Workshop contributors can set up PocketBase and Docusaurus with the dedicated
- `pnpm build` — production monorepo build.
- `docker build -t homarr .` — build a production Docker image.
- `docker run -p 7575:7575 -e SECRET_ENCRYPTION_KEY='your_64_character_hex_string' homarr:<name>` — run it.
## CI and focused validation
The main CI workflow runs lint, typechecking, affected workspace builds, workspace configuration checks, OpenAPI validation, and Custom Widget architecture and bundle checks. Unit and E2E suites do not run in CI. Preview images publish after the Fast gate and both architecture builds succeed; browser tests do not block publication. The container job builds an amd64 image when no preview image was built.
Workshop CI runs only when its workflow or Docker build inputs change. It validates Compose configuration and publishes production images on its configured release branches. Its Docker integration and image tests are manual; workspace typechecks run in the main Fast gate.
Choose the smallest relevant suite when changing behavior:
| Command | Scope | Requirements |
| ------------------------------------------------- | -------------------------------------------------------------- | --------------------------------------------------------------------- |
| `pnpm test <file>` | Focused unit or contract tests | Workspace dependencies |
| `pnpm test` | All unit and contract tests, without Docker integration suites | Workspace dependencies |
| `pnpm test:coverage` | Unit and contract tests with coverage | Workspace dependencies |
| `pnpm test:integration <file>` | Docker-backed service and database compatibility tests | Docker |
| `pnpm test:e2e <file>` | Browser and container scenarios | Docker; Playwright Chromium and system dependencies for browser tests |
| `pnpm test:docs-screenshots` | Regenerate Assistant documentation screenshots | Same browser prerequisites |
| `pnpm test:workshop` / `pnpm test:workshop-image` | Workshop integration / production-image validation | Docker |
Omit `<file>` to run all tests in that command's scope. E2E and screenshot commands use a local image tagged `homarr-e2e`, or an image selected with `HOMARR_E2E_IMAGE`. The default commands run once without automatic retries; coverage is opt-in. The screenshot generator is excluded from both unit and E2E commands.
Keep behavioral coverage for authorization, migrations, persistence, and widget execution. Avoid tests that only search source code for particular component names, JSX, CSS, or documentation wording.
@@ -1,43 +1,12 @@
import { readFileSync } from "node:fs";
import { describe, expect, it } from "vitest";
import { buildCustomWidgetAiPrompt } from "@homarr/custom-widgets/authoring-prompt";
describe("Copy AI prompt", () => {
it("uses one self-contained prompt without an embedded-skill branch", () => {
const source = readFileSync(
`${process.cwd()}/apps/nextjs/src/app/[locale]/manage/custom-widgets/_copy-ai-prompt-button.tsx`,
"utf8",
);
expect(source).toContain("buildCustomWidgetAiPrompt");
expect(source).not.toContain("embedded-authoring-prompt");
expect(source).not.toContain("Menu");
});
it("copies a complete prompt within the public size limit", () => {
const prompt = buildCustomWidgetAiPrompt(undefined, null, null, "Create a Pokédex");
expect(prompt.length).toBeLessThanOrEqual(12_000);
expect(prompt).toContain("exactly one complete `json` fenced block");
expect(prompt).toContain("Put the complete JSX source directly in the `template` string");
});
it("keeps AI actions and section navigation readable on narrow screens", () => {
const card = readFileSync(
`${process.cwd()}/apps/nextjs/src/app/[locale]/manage/custom-widgets/_custom-widget-ai-card.tsx`,
"utf8",
);
const styles = readFileSync(
`${process.cwd()}/apps/nextjs/src/app/[locale]/manage/custom-widgets/_custom-widget-form.module.css`,
"utf8",
);
const actions = readFileSync(
`${process.cwd()}/apps/nextjs/src/app/[locale]/manage/custom-widgets/_copy-ai-prompt-button.tsx`,
"utf8",
);
expect(card).toContain('<Accordion variant="contained">');
expect(card).not.toContain("defaultValue");
expect(actions).toContain("<ActionIcon");
expect(styles).toContain(".sectionNav > a");
expect(styles).toContain("flex: 0 0 auto");
});
});
@@ -1,36 +0,0 @@
import { readFileSync } from "node:fs";
import { describe, expect, it } from "vitest";
const readSource = (path: string) => readFileSync(`${process.cwd()}/${path}`, "utf8");
describe("legacy custom widget migration UI", () => {
it("keeps legacy definitions visible and exposes the redacted LLM workflow", () => {
const list = readSource("apps/nextjs/src/app/[locale]/manage/custom-widgets/_custom-widget-list.tsx");
const actions = readSource("apps/nextjs/src/app/[locale]/manage/custom-widgets/_custom-widget-actions.tsx");
expect(list).toContain("widget.migrationRequired");
expect(list).toContain('t("page.list.migrationInstructions")');
expect(actions).toContain("legacyMigrationPrompt.fetch");
expect(actions).toContain("navigator.clipboard.writeText(result.prompt)");
expect(actions).toContain("navigator.clipboard.readText()");
expect(actions).toContain('t("action.pasteMigration")');
expect(actions).toContain("parseCustomWidgetClipboardDetailed");
expect(actions).toContain("legacyId={widget.id}");
});
it("replaces the legacy record through migration and refreshes every consumer", () => {
// The import pipeline is shared by the dialog and the Workshop install page.
const importer = readSource("apps/nextjs/src/components/custom-widgets/use-custom-widget-import.ts");
const dialog = readSource("apps/nextjs/src/components/custom-widgets/custom-widget-import-dialog.tsx");
expect(importer).toContain("customWidget.migrateLegacy.useMutation");
expect(importer).toMatch(
/migrateMutation\.mutate\(\{\s*id: legacyId,\s*widget: configuredWidget,\s*secrets,?\s*\}\)/u,
);
expect(importer).toContain("utils.customWidget.list.invalidate()");
expect(importer).toContain("utils.customWidget.available.invalidate()");
expect(importer).toContain("utils.widget.customApi.getData.invalidate()");
expect(dialog).toContain("useCustomWidgetImport({");
expect(dialog).toContain("legacyId,");
});
});
@@ -1,6 +1,5 @@
// @vitest-environment jsdom
import { readFileSync } from "node:fs";
import { act } from "react";
import { createRoot } from "react-dom/client";
import type { Root } from "react-dom/client";
@@ -76,16 +75,6 @@ afterEach(async () => {
});
describe("Custom Widget preview panel", () => {
test("inherits the application theme instead of forcing a reload-sensitive preview theme", () => {
const source = readFileSync(
`${process.cwd()}/apps/nextjs/src/app/[locale]/manage/custom-widgets/_custom-widget-preview-panel.tsx`,
"utf8",
);
expect(source).not.toContain("forceColorScheme");
expect(source).not.toContain("onThemeChange");
});
test("contains invalid definitions inside the widget canvas while keeping every inspection tab", async () => {
await act(async () => {
root.render(
@@ -1,18 +0,0 @@
import { readFileSync } from "node:fs";
import { describe, expect, it } from "vitest";
describe("DynamicBreadcrumb", () => {
it("falls back to the raw segment when a path segment has no translation", () => {
const source = readFileSync(
`${process.cwd()}/apps/nextjs/src/components/navigation/dynamic-breadcrumb.tsx`,
"utf8",
);
// Dynamic ids (e.g. /manage/custom-widgets/workshop/<id>) have no message and are
// not mapped while their page loads; resolving them must not raise MISSING_MESSAGE.
expect(source).toContain("let label = pathnamePart");
expect(source).toContain("if (mappedValue !== undefined)");
expect(source).toContain("t.has(labelKey)");
expect(source).not.toMatch(/\{t\(`\$\{translationKey\}\.label`/u);
});
});
@@ -1,18 +1,9 @@
// @vitest-environment jsdom
import { readFileSync } from "node:fs";
import { afterEach, describe, expect, it } from "vitest";
import { getWorkshopAssistantProviderUrl, getWorkshopWebUrl } from "./workshop-client";
const read = (path: string) => readFileSync(`${process.cwd()}/${path}`, "utf8");
const detailPage = "apps/nextjs/src/app/[locale]/manage/custom-widgets/workshop/[id]/_workshop-detail.tsx";
const browsePage = "apps/nextjs/src/app/[locale]/manage/custom-widgets/workshop/_workshop-browse.tsx";
const browseRoutePage = "apps/nextjs/src/app/[locale]/manage/custom-widgets/workshop/page.tsx";
const publishPage = "apps/nextjs/src/app/[locale]/manage/custom-widgets/publish/[id]/_workshop-publish-form.tsx";
const cssButton = "apps/nextjs/src/components/workshop/workshop-css-import-button.tsx";
describe("Workshop widget install flow", () => {
afterEach(() => {
document.head.innerHTML = "";
@@ -39,136 +30,4 @@ describe("Workshop widget install flow", () => {
expect(getWorkshopAssistantProviderUrl()).toBe("https://api.preview.example/api/ai/v1");
});
it("installs from a page with a sticky footer instead of stacked modals", () => {
const detail = read(detailPage);
expect(detail).toContain("ManageStickyFooter");
expect(detail).toContain("useCustomWidgetImport");
// Review and install happen on one surface, so neither Modal.Stack nor the review dialog is involved.
expect(detail).not.toContain("Modal.Stack");
expect(detail).not.toContain("useModalsStack");
expect(detail).not.toContain("CustomWidgetImportDialog");
});
it("browses the Workshop as a card grid with per-item Workshop links", () => {
const browse = read(browsePage);
const route = read(browseRoutePage);
expect(browse).toContain("WorkshopSubmissionGrid");
expect(browse).toContain("SearchInput");
expect(browse).toContain("TablePagination");
expect(browse).toContain("CustomWidgetTabs");
expect(route).toContain("resolveHomarrUrlConfig");
expect(route).toContain("workshopWebUrl={workshopWebUrl}");
expect(browse).toContain("workshopWebUrl: string");
expect(browse).toContain("href={workshopWebUrl}");
expect(browse).toContain('workshopWebUrl.replace(/\\/+$/u, "")');
expect(browse).toContain("encodeURIComponent(item.id)");
expect(browse).toContain("WorkshopVoteControl");
expect(browse).not.toContain("Modal");
});
it("renders the grid with real cards and screenshot previews", () => {
const grid = read("apps/nextjs/src/components/workshop/workshop-submission-grid.tsx");
const card = read("apps/nextjs/src/components/workshop/workshop-submission-card.tsx");
expect(grid).toContain("SimpleGrid");
expect(card).toContain("Card.Section");
expect(card).toContain("PREVIEW_HEIGHT");
});
it("shows the viewer's own vote as a filled control", () => {
const vote = read("apps/nextjs/src/components/workshop/workshop-vote-control.tsx");
expect(vote).toContain("useWorkshopUserVotesQuery");
expect(vote).toContain("IconArrowBigUpFilled");
expect(vote).toContain("IconArrowBigDownFilled");
expect(vote).toContain('color={upvoted ? "red" : "gray"}');
expect(vote).toContain("aria-pressed={upvoted}");
});
it("previews screenshots large with a zoom lightbox", () => {
const screenshots = read("apps/nextjs/src/components/workshop/workshop-screenshots.tsx");
const detail = read(detailPage);
expect(screenshots).toContain("1200x800");
expect(screenshots).toContain("zoomScreenshot");
expect(screenshots).toContain('import { Lightbox } from "@mantine/lightbox"');
expect(screenshots).toContain("withThumbnails");
expect(screenshots).toContain("withZoom");
expect(screenshots).toContain("withKeyboardEvents");
expect(screenshots).not.toMatch(/<Modal\b/u);
expect(detail).toContain("WorkshopScreenshots");
});
it("publishes from a page with the same sticky footer", () => {
const publish = read(publishPage);
expect(publish).toContain("ManageStickyFooter");
expect(publish).not.toContain("useModalsStack");
expect(publish).not.toMatch(/<Modal\b/u);
});
it("imports Custom CSS from a single modal", () => {
const css = read(cssButton);
expect(css).toContain("WorkshopSubmissionGrid");
expect(css).toContain("useConfirmModal");
expect(css).not.toContain("useModalsStack");
expect(css).not.toContain("Modal.Stack");
});
it("cannot import the same widget twice and returns to the installed list", () => {
const importer = read("apps/nextjs/src/components/custom-widgets/use-custom-widget-import.ts");
const detail = read(detailPage);
// The mutation creates a new record per call, so a second click must be impossible.
expect(importer).toContain("if (!configuredWidget || pending || succeeded) return;");
expect(importer).toContain("setSucceeded(true)");
expect(detail).toContain("router.push(installedHref)");
expect(detail).toContain("disabled={!compatible || !importer.ready || importer.succeeded}");
// The heavy edit form is no longer the post-install destination.
expect(detail).not.toContain("/manage/custom-widgets/edit/");
});
it("keeps typed credentials across a background refetch of the submission", () => {
const importer = read("apps/nextjs/src/components/custom-widgets/use-custom-widget-import.ts");
const detail = read(detailPage);
// Voting invalidates the detail query. If the setup reset keyed off the widget
// object identity, the refetch would silently clear part-typed API keys.
expect(importer).toContain("const sourcesKey = JSON.stringify(widget?.sources ?? {})");
expect(importer).toContain("[sourcesKey]");
expect(importer).not.toContain("getCustomWidgetSourceSetups(widget?.sources ?? {}), [widget])");
expect(detail).toContain("const content = detail.data?.content;");
expect(detail).toContain("[content]");
});
it("keeps the sign-in hint reachable when voting is unavailable", () => {
const vote = read("apps/nextjs/src/components/workshop/workshop-vote-control.tsx");
// Mantine tooltips do not fire on disabled controls, so the hint hangs off the wrapper.
expect(vote).toContain('canVote ? controls : <Tooltip label={t("signInHint")}>{controls}</Tooltip>');
});
it("cannot publish the same widget twice", () => {
const publish = read(publishPage);
expect(publish).toContain("if (createSubmission.isPending) return;");
expect(publish).toContain("createSubmission.isPending ||");
});
it("no longer ships the stacked-modal install components", () => {
for (const removed of [
"apps/nextjs/src/components/workshop/workshop-browser.tsx",
"apps/nextjs/src/components/workshop/workshop-install-button.tsx",
"apps/nextjs/src/components/workshop/workshop-installer.tsx",
"apps/nextjs/src/components/workshop/workshop-publish-modal.tsx",
"apps/nextjs/src/components/workshop/workshop-submission-list.tsx",
"apps/nextjs/src/components/workshop/workshop-submission-item.tsx",
]) {
expect(() => read(removed)).toThrow();
}
});
});
+1 -1
View File
@@ -77,7 +77,7 @@ pnpm --filter @homarr/docs typecheck
```
The integration suite uses a disposable Compose project. `pnpm test:workshop-image` additionally builds and smoke-tests
the combined production image.
the combined production image. Both commands are manual and do not run in CI. Workshop CI validates Compose configuration only when its workflow or Docker build inputs change, then publishes images on release branches. The main Fast gate owns workspace typechecking.
## Data safety
+3 -113
View File
@@ -1,48 +1,7 @@
import { access, readFile } from "node:fs/promises";
import { resolve } from "node:path";
import { readFile } from "node:fs/promises";
import vm from "node:vm";
const read = (path) => readFile(resolve(path), "utf8");
const packageJson = JSON.parse(await read("packages/workshop/package.json"));
const clientExport = packageJson.exports?.["./client"];
if (typeof clientExport !== "string") throw new Error("Workshop client export is missing");
await access(resolve("packages/workshop", clientExport));
const hook = await read("apps/workshop/pb_hooks/workshop.pb.js");
if (!hook.includes("require(`${__hooks}/workshop-utils.js`)")) {
throw new Error("Workshop handlers must load shared helpers inside their isolated PocketBase contexts");
}
if (!hook.includes("onBootstrap") || !hook.includes("users.oauth2.providers = configured")) {
throw new Error("Workshop OAuth settings must be synchronized at every bootstrap");
}
if (!hook.includes('username: "name"') || hook.includes("onRecordAuthWithOAuth2Request")) {
throw new Error("Workshop identity must use PocketBase's direct GitHub username mapping");
}
if (hook.includes("validateAndNormalizeSubmission")) {
throw new Error("PocketBase must store Workshop submissions without interpreting their content");
}
for (const artifact of [
"apps/workshop/pb_hooks/widget-validator.js",
"apps/workshop/pb_hooks/widget-validator.bundle.cjs",
"packages/workshop/src/pocketbase-validator.ts",
"scripts/build-workshop-validator.mjs",
]) {
await access(resolve(artifact))
.then(() => {
throw new Error(`Workshop validator artifact must stay removed: ${artifact}`);
})
.catch((error) => {
if (error instanceof Error && "code" in error && error.code === "ENOENT") return;
throw error;
});
}
if (hook.includes('findAllRecords("users")') || !hook.includes('findRecordsByFilter("users"')) {
throw new Error("Workshop report notifications must use a filtered, bounded administrator query");
}
if (!hook.includes('routerAdd("GET", "/workshop/{id}"') || !hook.includes('getStringSlice("screenshots")')) {
throw new Error("Workshop detail routes must render server-side social metadata with submission screenshots");
}
const read = (path) => readFile(path, "utf8");
const hookUtilsModule = { exports: {} };
vm.runInNewContext(await read("apps/workshop/pb_hooks/workshop-utils.js"), { module: hookUtilsModule });
@@ -69,73 +28,4 @@ for (const expected of [
}
if (socialHtml.includes("Homarr documentation")) throw new Error("Workshop social metadata must replace generic tags");
const migration = await read("apps/workshop/pb_migrations/1784240000_workshop_widgets.js");
for (const removedField of ["displayName", "avatarUrl", "githubProfileUrl", "githubUsername"]) {
if (migration.includes(removedField)) throw new Error(`Redundant Workshop user field remains: ${removedField}`);
}
const providerMigration = await read("apps/workshop/pb_migrations/1786500000_homarr_provider.js");
for (const required of ['name: "assistant_quotas"', 'name: "assistant_global_quota"', "idx_assistant_quotas_user"]) {
if (!providerMigration.includes(required)) throw new Error(`Homarr provider migration is missing: ${required}`);
}
for (const forbidden of ['name: "assistant_requests"', 'name: "assistant_activity"', 'name: "dailyLimit"']) {
if (providerMigration.includes(forbidden)) throw new Error(`Homarr provider must not persist ${forbidden}`);
}
const dailyQuotaMigration = await read("apps/workshop/pb_migrations/1787670446_daily_assistant_quotas.js");
for (const required of [
'removeIndex("idx_assistant_quotas_user")',
'addIndex("idx_assistant_quotas_user_day", true, "user, day", "")',
]) {
if (!dailyQuotaMigration.includes(required)) throw new Error(`Daily quota migration is missing: ${required}`);
}
const providerBackend = await read("apps/workshop/homarr_provider.go");
for (const required of [
'"homarr/model"',
'apis.RequireAuth("users")',
"DefaultActivityLoggerMiddlewareId",
'os.Getenv("OPENROUTER_API_KEY")',
'os.Getenv("HOMARR_AI_DAILY_REQUEST_LIMIT")',
'os.Getenv("HOMARR_AI_GLOBAL_DAILY_REQUEST_LIMIT")',
'os.Getenv("HOMARR_AI_OPENROUTER_MODEL")',
'"data_collection": "deny"',
'"zdr": true',
]) {
if (!providerBackend.includes(required)) throw new Error(`Homarr provider backend is missing: ${required}`);
}
if (/logger\(\)|\.Logger\(|log\.Print/u.test(providerBackend)) {
throw new Error("The Homarr provider must not log request content or user tokens");
}
for (const required of [
"workshop_migration_state",
"addedUserFields",
"cloneRule(users.listRule)",
"CREATE TRIGGER submissions_revision_cas",
"NEW.expectedRevision != OLD.revision",
"users.passwordAuth = state.users.passwordAuth",
"users.oauth2 = state.users.oauth2",
"app.delete(users)",
]) {
if (!migration.includes(required)) throw new Error(`Workshop rollback is missing state restoration: ${required}`);
}
const workflow = await read(".github/workflows/workshop.yml");
if (/\n\s+paths:/u.test(workflow)) throw new Error("Workshop workflow must not filter out Docker build inputs");
const entrypoint = await read("apps/workshop/entrypoint.sh");
if (!entrypoint.includes("/pb_public/workshop-runtime-config.js") || !entrypoint.includes("WORKSHOP_API_URL")) {
throw new Error("Workshop must publish its API URL when the container starts");
}
const docsConfig = await read("apps/docs/docusaurus.config.ts");
if (!docsConfig.includes('scripts: [{ src: "/workshop-runtime-config.js" }]')) {
throw new Error("Workshop must load the container runtime configuration before the documentation bundle");
}
if (!docsConfig.includes('path: "/workshop/:id"') || !docsConfig.includes("priority: -1")) {
throw new Error("Workshop submission routes must not capture the moderation page");
}
await access(resolve("apps/docs/static/workshop-runtime-config.js"));
await access(resolve("apps/docs/src/pages/workshop/admin/index.tsx"));
console.log("Workshop static contracts passed");
console.log("Workshop social metadata rendering passed");
+1 -2
View File
@@ -16,8 +16,7 @@ const adminCredentials = {
password: "Comp(exP4sswOrd",
};
// This is an opt-in documentation asset generator, not part of the normal E2E gate.
// Run it explicitly with UPDATE_DOCS_SCREENSHOTS=true when the documented UI changes.
// Run this opt-in asset generator with pnpm test:docs-screenshots when the documented UI changes.
describe.skipIf(process.env.UPDATE_DOCS_SCREENSHOTS !== "true")("Assistant documentation screenshots", () => {
test("captures the management page and conversation panel", async () => {
const { db, localMountPath } = await createSqliteDbFileAsync();
-114
View File
@@ -1,114 +0,0 @@
import { chromium, expect } from "@playwright/test";
import type { Browser } from "@playwright/test";
import type { StartedTestContainer } from "testcontainers";
import { describe, test } from "vitest";
import { createHomarrContainer } from "./shared/create-homarr-container";
import { createSqliteDbFileAsync } from "./shared/e2e-db";
import type { MockApiServer } from "./shared/mock-api-server";
import { startMockApiServerAsync } from "./shared/mock-api-server";
import { seedAdminUserAsync } from "./shared/seed-admin-user";
const adminCredentials = { username: "admin", password: "Comp(exP4sswOrd" };
const initialTemplate =
"<Stack><Title order={4}>{data.status?.title}</Title><Badge>{data.status?.status}</Badge></Stack>";
const updatedTemplate = "<Stack><Text fw={700}>{data.status?.title}</Text><Text>{data.status?.value}</Text></Stack>";
describe("Custom JSX v2 workbench", () => {
test.skip("creates, previews, edits, and immediately adds a widget to a board", async () => {
let mockApi: MockApiServer | undefined;
let homarrContainer: StartedTestContainer | undefined;
let browser: Browser | undefined;
try {
mockApi = await startMockApiServerAsync({ title: "E2E Widget", status: "online", value: 42 });
const { db, localMountPath } = await createSqliteDbFileAsync();
await seedAdminUserAsync(db, adminCredentials);
homarrContainer = await createHomarrContainer({
environment: { AUTH_PROVIDERS: "credentials" },
mounts: { "/appdata": localMountPath },
}).start();
const baseUrl = `http://${homarrContainer.getHost()}:${homarrContainer.getMappedPort(7575)}`;
browser = await chromium.launch();
const page = await browser.newPage();
await page.goto(`${baseUrl}/auth/login`);
await page.waitForLoadState("networkidle");
await page.getByLabel("Username").fill(adminCredentials.username);
await page.locator("#password").fill(adminCredentials.password);
const signedIn = page.waitForURL((url) => url.origin === baseUrl && url.pathname !== "/auth/login", {
waitUntil: "commit",
timeout: 60_000,
});
await page.locator("button[type='submit']").click();
await signedIn;
await page.goto(`${baseUrl}/manage/custom-widgets/new`);
await page.locator('input[data-path="name"]:visible').fill("E2E Custom JSX v2");
await page.getByRole("textbox", { name: "Base URL" }).fill(mockApi.url);
await page.getByRole("combobox", { name: "Network access" }).click();
await page.getByRole("option", { name: "private" }).click();
await page.getByRole("group", { name: "status" }).getByRole("textbox", { name: "Path" }).fill("/status");
await page.getByRole("button", { name: "Add option" }).click();
const option = page.getByRole("group", { name: "Option 1" });
await option.getByRole("textbox", { name: "Option name" }).fill("label");
await option.getByRole("textbox", { name: "Default" }).fill("E2E");
const request = page.getByRole("group", { name: "status" });
await request.getByRole("button", { name: "Query, body and behavior" }).click();
await request.getByRole("button", { name: "Add query value" }).click();
await request.getByRole("textbox", { name: "Query key" }).fill("label");
await request.getByRole("combobox", { name: "Value source" }).click();
await page.getByRole("option", { name: "Widget option" }).click();
await request.getByRole("combobox", { name: "Option" }).first().click();
await page.getByRole("option", { name: "label" }).click();
await page.getByRole("button", { name: "Advanced manifest JSON" }).first().click();
await expect(page.locator("#requests-editor")).toContainText('"$option": "label"');
await page.locator("#jsx-editor").fill(initialTemplate);
await page.getByRole("button", { name: "Test and preview" }).last().click();
await expect(page.getByRole("complementary", { name: "Widget preview" }).getByText("E2E Widget")).toBeVisible({
timeout: 15_000,
});
const editPage = page.waitForURL("**/manage/custom-widgets/edit/**", { timeout: 15_000, waitUntil: "commit" });
await page.getByRole("button", { name: "Create", exact: true }).last().click();
await editPage;
await page.locator("#jsx-editor").fill(updatedTemplate);
await page.getByRole("button", { name: "Save", exact: true }).last().click();
await expect(page.getByText('Widget "E2E Custom JSX v2" updated successfully.')).toBeVisible({ timeout: 15_000 });
await page.goto(baseUrl);
const tourOverlay = page.locator('[data-onboarding-tour-overlay="true"]');
const skipTour = page.getByRole("button", { name: "Skip tour", exact: true });
try {
await skipTour.waitFor({ state: "visible", timeout: 5_000 });
await skipTour.click();
await expect(tourOverlay).toHaveCount(0);
} catch (error) {
if ((await tourOverlay.count()) > 0) throw error;
}
const editToggle = page.getByTestId("board-edit-mode-toggle");
await editToggle.click();
await expect(editToggle).toHaveAttribute("aria-pressed", "true", { timeout: 15_000 });
await page.getByRole("button", { name: "Add board content", exact: true }).click();
await page
.getByRole("dialog", { name: "Create" })
.getByRole("button", { name: /^Add widget / })
.click();
const picker = page.getByRole("dialog").last();
await picker.getByRole("button", { name: /^E2E Custom JSX v2,/ }).click();
const addDialog = page.getByRole("dialog").last();
await expect(addDialog.getByText("E2E Custom JSX v2")).toBeVisible();
await addDialog.getByRole("button", { name: "Save changes", exact: true }).click();
await expect(page.getByText("Widget definition not found")).not.toBeVisible();
await editToggle.click();
await expect(editToggle).toHaveAttribute("aria-pressed", "false", { timeout: 15_000 });
await expect(page.getByText("E2E Widget")).toBeVisible({ timeout: 15_000 });
await expect(page.getByText("42")).toBeVisible({ timeout: 15_000 });
} finally {
await Promise.allSettled([browser?.close(), homarrContainer?.stop(), mockApi?.close()]);
}
}, 180_000);
});
-20
View File
@@ -1,20 +0,0 @@
import { describe, expect, test } from "vitest";
import { createHomarrContainer } from "./shared/create-homarr-container";
describe("Home", () => {
test("should open with status code 200", async () => {
// Arrange
const homarrContainer = await createHomarrContainer().start();
try {
// Act
const homeResponse = await fetch(`http://localhost:${homarrContainer.getMappedPort(7575)}/`);
// Assert
expect(homeResponse.status).toBe(200);
} finally {
await homarrContainer.stop();
}
}, 60_000);
});
+1 -100
View File
@@ -1,11 +1,9 @@
import { mkdir, readFile, rm } from "node:fs/promises";
import { mkdir, rm } from "node:fs/promises";
import path from "node:path";
import { chromium } from "@playwright/test";
import type { Page } from "@playwright/test";
import { createId } from "@paralleldrive/cuid2";
import AdmZip from "adm-zip";
import type { StartedTestContainer } from "testcontainers";
import { describe, expect, test } from "vitest";
import * as sqliteSchema from "../packages/db/schema/sqlite";
@@ -30,48 +28,6 @@ const cleanupSqliteDbAsync = async ({ db, localMountPath }: SqliteTestDatabase)
await rm(localMountPath, { recursive: true, force: true });
};
const createRestoreArchiveAsync = async () => {
const sqlite = await createSqliteDbFileAsync();
const { db, localMountPath } = sqlite;
try {
await db.insert(sqliteSchema.onboarding).values({ id: "onboarding", step: "finish", previousStep: "setup" });
await db.insert(sqliteSchema.boards).values({ id: "restored-board", name: "restored", isPublic: true });
await db.insert(sqliteSchema.layouts).values({
id: "restored-base",
boardId: "restored-board",
name: "Base",
role: "base",
columnCount: 10,
breakpoint: 768,
});
await db.insert(sqliteSchema.sections).values({
id: "restored-root",
boardId: "restored-board",
kind: "empty",
xOffset: 0,
yOffset: 0,
});
db.$client.close();
const zip = new AdmZip();
zip.addFile("db.sqlite", await readFile(path.join(localMountPath, "db", "db.sqlite")));
zip.addFile(
"metadata.json",
Buffer.from(
JSON.stringify({
homarrVersion: "e2e",
exportedAt: new Date().toISOString(),
dbDialect: "sqlite",
encryptionKey: "0".repeat(64),
}),
),
);
return zip.toBuffer();
} finally {
await cleanupSqliteDbAsync(sqlite);
}
};
describe("Onboarding", () => {
test.each([
{ name: "mobile", width: 320, height: 740 },
@@ -180,61 +136,6 @@ describe("Onboarding", () => {
90_000,
);
test.skip("SQLite onboarding restores a validated backup and survives the restart", async () => {
const restoreArchive = await createRestoreArchiveAsync();
const invalidArchive = new AdmZip();
invalidArchive.addFile("db.sqlite", Buffer.from("not-a-database"));
const sqlite = await createSqliteDbFileAsync();
const { localMountPath } = sqlite;
const initialContainer = await createHomarrContainer({ mounts: { "/appdata": localMountPath } }).start();
let initialContainerStopped = false;
let restoredContainer: StartedTestContainer | undefined;
const browser = await chromium.launch();
const context = await browser.newContext();
const page = await context.newPage();
try {
const initialBaseUrl = `http://${initialContainer.getHost()}:${initialContainer.getMappedPort(7575)}`;
await page.goto(`${initialBaseUrl}/init`);
await page.getByRole("button", { name: "Restore backup" }).click();
const fileInput = page.locator('input[type="file"]');
await fileInput.setInputFiles({
name: "missing-metadata.zip",
mimeType: "application/zip",
buffer: invalidArchive.toBuffer(),
});
await page.getByText("Invalid backup: missing metadata.json").waitFor();
await page.getByRole("button", { name: "Change file" }).click();
await fileInput.setInputFiles({ name: "homarr-backup.zip", mimeType: "application/zip", buffer: restoreArchive });
await page.getByRole("heading", { name: "Backup Contents" }).waitFor({ timeout: 30_000 });
expect(await page.getByText("restored", { exact: true }).isVisible()).toBe(true);
await page.getByRole("button", { name: "Continue to restore" }).click();
await page.getByPlaceholder("I understand").fill("I understand");
const restoreResponse = page.waitForResponse(
(response) => response.url().endsWith("/api/backup/import") && response.request().method() === "POST",
);
await page.getByRole("button", { name: "Restore Database" }).click();
expect((await restoreResponse).status()).toBe(200);
await initialContainer.stop();
initialContainerStopped = true;
restoredContainer = await createHomarrContainer({ mounts: { "/appdata": localMountPath } }).start();
const restoredBaseUrl = `http://${restoredContainer.getHost()}:${restoredContainer.getMappedPort(7575)}`;
await page.goto(`${restoredBaseUrl}/boards/restored`);
await page.locator('[data-testid="board-canvas"][data-board-hydrated="true"]').waitFor({ timeout: 30_000 });
expect(page.url()).toMatch(/\/boards\/restored(?:[/?#]|$)/);
} finally {
try {
await browser.close();
if (!initialContainerStopped) await initialContainer.stop();
if (restoredContainer) await restoredContainer.stop();
} finally {
await cleanupSqliteDbAsync(sqlite);
}
}
}, 120_000);
test("Credentials onboarding recovers when automatic sign-in fails after account creation", async () => {
const sqlite = await createSqliteDbFileAsync();
const { db, localMountPath } = sqlite;
-50
View File
@@ -1,50 +0,0 @@
import { createServer } from "node:http";
import { getContainerRuntimeClient, TestContainers } from "testcontainers";
import type { Server } from "node:http";
const TESTCONTAINERS_SSHD_LABEL = "org.testcontainers.sshd";
export interface MockApiServer {
url: string;
close: () => Promise<void>;
}
export async function startMockApiServerAsync(responseBody: unknown): Promise<MockApiServer> {
const body = JSON.stringify(responseBody);
const server: Server = await new Promise((resolve) => {
const candidate = createServer((_request, response) => {
response.writeHead(200, { "content-type": "application/json" });
response.end(body);
});
candidate.listen(0, "0.0.0.0", () => resolve(candidate));
});
const address = server.address();
if (!address || typeof address === "string") {
server.close();
throw new Error("Failed to start mock API server");
}
try {
await TestContainers.exposeHostPorts(address.port);
const client = await getContainerRuntimeClient();
const forwarder = (await client.container.list()).find(
(container) => container.State === "running" && container.Labels[TESTCONTAINERS_SSHD_LABEL] === "true",
);
const forwarderAddress = forwarder
? Object.values(forwarder.NetworkSettings.Networks).find((network) => network.IPAddress)?.IPAddress
: undefined;
if (!forwarderAddress) throw new Error("Failed to locate the Testcontainers host-port forwarder");
return {
url: `http://${forwarderAddress}:${address.port}`,
close: () =>
new Promise((resolve, reject) => {
server.close((error) => (error ? reject(error) : resolve()));
}),
};
} catch (error) {
server.close();
throw error;
}
}
+6 -3
View File
@@ -49,12 +49,15 @@
"scripts:update-openapi": "tsx ./scripts/check-openapi-schema.mts --write",
"scripts:update-readme-integrations": "tsx ./scripts/update-integration-list.mts",
"start": "pnpm -F nextjs start",
"test": "cross-env NODE_ENV=development CI=true vitest run --project \"!e2e\" --retry 2 --coverage.enabled ",
"test:e2e": "cross-env NODE_ENV=development CI=true vitest e2e --retry 2 --maxWorkers 2",
"test": "cross-env NODE_ENV=development CI=true vitest run --project \"!e2e\" --project \"!integration\" --project \"!docs-screenshots\"",
"test:coverage": "pnpm test --coverage.enabled",
"test:integration": "cross-env NODE_ENV=development CI=true vitest run --project integration --maxWorkers 2",
"test:docs-screenshots": "cross-env UPDATE_DOCS_SCREENSHOTS=true NODE_ENV=development CI=true vitest run --project docs-screenshots",
"test:e2e": "cross-env NODE_ENV=development CI=true vitest run --project e2e --maxWorkers 2",
"test:workshop": "sh apps/workshop/test.sh",
"test:workshop-live-provider": "node apps/workshop/tests/openrouter-live-smoke.mjs",
"test:workshop-image": "sh apps/workshop/test-image.sh",
"test:ui": "cross-env NODE_ENV=development CI=true vitest --project \"!e2e\" --ui --coverage.enabled",
"test:ui": "cross-env NODE_ENV=development CI=true vitest --project \"!e2e\" --project \"!integration\" --project \"!docs-screenshots\" --ui",
"typecheck": "turbo typecheck",
"with-env": "dotenv -e .env --"
},
@@ -37,12 +37,6 @@ describe("authoring resources", () => {
expect(bundledContent).toContain(content.trim());
}
expect(bundledContent).toContain(skill.skillMd.trim());
expect(bundledContent).toContain("coordinated set of widgets");
expect(bundledContent).toContain("customWidget_validateTemplate");
expect(bundledContent).toContain("customWidget_findComponents");
expect(bundledContent).toContain("Batch selected non-obvious binding or interaction documentation");
expect(bundledContent).not.toContain("at most eight named component documents");
expect(bundledContent).toContain("Do not use an unlabeled decorative icon as an empty state");
expect(Buffer.byteLength(JSON.stringify(skill), "utf8")).toBeLessThan(12_000);
const entrypoint = getCustomWidgetSkillEntrypoint();
@@ -50,36 +44,7 @@ describe("authoring resources", () => {
expect.arrayContaining([expect.objectContaining({ name: "runtime", tool: "customWidget_getReference" })]),
);
expect(JSON.stringify(entrypoint)).not.toContain("# Runtime");
expect(entrypoint.skillMd).toContain("Context routing");
expect(entrypoint.skillMd).toContain("customWidget_validateTemplate");
expect(entrypoint.skillMd).toContain("customWidget_previewReviseTemplate");
expect(entrypoint.skillMd).toContain("map every requested capability");
expect(entrypoint.skillMd).toContain("purpose-specific visual signature");
expect(entrypoint.skillMd).toContain("asymmetric priority metric/action");
expect(entrypoint.skillMd).toContain("new manifest");
expect(entrypoint.skillMd).not.toContain("To install an existing widget");
expect(getCustomWidgetSkillReference("runtime").content).toContain("SubFetch");
expect(getCustomWidgetSkillReference("runtime").content).toContain(
'require `trigger: "manual"` on request and `SubFetch`',
);
expect(getCustomWidgetSkillReference("runtime").content).toContain("RefreshButton");
expect(getCustomWidgetSkillReference("runtime").content).toContain("owns loading/error/retry");
expect(getCustomWidgetSkillReference("runtime").content).toContain("need literal `requestId`");
expect(getCustomWidgetSkillReference("runtime").content).toContain('requestId="search" label="Run again"');
expect(getCustomWidgetSkillReference("runtime").content).toContain("Render load queries directly");
expect(getCustomWidgetSkillReference("runtime").content).toContain("entire JSON response");
expect(getCustomWidgetSkillReference("runtime").content).toContain("never map the envelope itself");
expect(getCustomWidgetSkillReference("runtime").content).toContain("immediately hides its prior result");
expect(getCustomWidgetSkillReference("runtime").content).toContain("Every stateful control must use `bind`");
expect(getCustomWidgetSkillReference("runtime").content).toContain("resetKey={inputs.search}");
expect(getCustomWidgetSkillReference("runtime").content).toContain('Date.toLocaleString(value, "en-US", "UTC")');
expect(getCustomWidgetSkillReference("runtime").content).toContain("Never invent a formatter component");
const schemaReference = getCustomWidgetSkillReference("schema").content;
expect(schemaReference).toContain('"default": {');
expect(schemaReference).toContain('"trigger": "manual"');
expect(schemaReference).toContain('"kind": "action"');
expect(schemaReference).toContain('"apiKeyHeader"');
expect(schemaReference).toContain("is the required source ID");
expect(Buffer.byteLength(schemaReference, "utf8")).toBeLessThan(4_000);
expect(Buffer.byteLength(JSON.stringify(entrypoint), "utf8")).toBeLessThan(3_000);
expect(Buffer.byteLength(JSON.stringify(entrypoint), "utf8")).toBeLessThan(
@@ -107,8 +72,6 @@ describe("authoring resources", () => {
const serviceDashboard = getCustomWidgetExample("service-dashboard");
if (!serviceDashboard) throw new Error("Service dashboard example is missing");
expect(validateCustomJsxTemplate(serviceDashboard.widget.template)).toEqual([]);
expect(serviceDashboard.widget.template.match(/<Paper/gu)).toHaveLength(1);
expect(serviceDashboard.widget.template).toContain('RefreshButton label="Refresh service health"');
expect(Buffer.byteLength(JSON.stringify(pokedex), "utf8")).toBeLessThan(16_000);
expect(catalog.sharedProps).toMatchObject({
count: expect.any(Number),
@@ -1,74 +0,0 @@
import { readFile } from "node:fs/promises";
import { resolve } from "node:path";
import { describe, expect, test } from "vitest";
const languageDirectory = resolve(import.meta.dirname, "../../../translation/src/lang");
describe("Workshop navigation translations", () => {
test("defines the breadcrumb labels for the Custom Widgets sub-routes", async () => {
const messages = JSON.parse(await readFile(resolve(languageDirectory, "en.json"), "utf8")) as {
management?: { navbar?: { items?: { workshop?: unknown } } };
navigationStructure?: {
manage?: {
workshop?: unknown;
"custom-widgets"?: {
workshop?: { label?: unknown };
publish?: { label?: unknown };
};
};
};
customWidget?: { page?: { tabs?: Record<string, unknown> } };
workshop?: Record<string, unknown>;
};
// The Workshop is reached through the Custom Widgets tabs, not its own navbar entry.
expect(messages.management?.navbar?.items?.workshop).toBeUndefined();
expect(messages.navigationStructure?.manage?.workshop).toBeUndefined();
expect(messages.navigationStructure?.manage?.["custom-widgets"]?.workshop?.label).toBe("Workshop");
expect(messages.navigationStructure?.manage?.["custom-widgets"]?.publish?.label).toBe("Publish to Workshop");
expect(messages.customWidget?.page?.tabs).toMatchObject({
installed: "Installed",
workshop: "Workshop",
ariaLabel: expect.any(String),
});
expect(messages.workshop).toMatchObject({
communityMember: "Community member",
outdated: "Outdated",
install: "Install",
back: expect.any(String),
listAriaLabel: expect.any(String),
installReviewTitle: expect.any(String),
emptyFilteredDescription: expect.any(String),
importCssConfirm: expect.any(String),
reportCount: expect.any(String),
reportWarning: expect.any(String),
reportVisibility: expect.any(String),
screenshotAlt: expect.any(String),
upvote: expect.any(String),
downvote: expect.any(String),
reportCategory: expect.objectContaining({
outdated: expect.any(String),
malicious: expect.any(String),
spam: expect.any(String),
copyright: expect.any(String),
inappropriate: expect.any(String),
other: expect.any(String),
}),
publish: expect.objectContaining({
invalidScreenshot: expect.any(String),
}),
});
});
test("drops the message keys that only existed for the stacked install modals", async () => {
const messages = JSON.parse(await readFile(resolve(languageDirectory, "en.json"), "utf8")) as {
workshop?: Record<string, unknown>;
};
for (const key of ["installDialog", "continueInstall", "confirmInstallTitle", "backToWidget", "inspectCss"]) {
expect(messages.workshop).not.toHaveProperty(key);
}
});
});
+57
View File
@@ -0,0 +1,57 @@
# Test pipeline audit — 2026-09-10
Audited the workflow definitions, Docker build targets, package test scripts, Vitest project inclusion/exclusion rules, and E2E entry points at release/v2 commit `3358b6337`. Test-file pruning targets inspected source-string assertions and unconditionally skipped scenarios; this is not a claim that every retained assertion has been individually reviewed or that no test has ever caught a regression.
## Measured baseline
Latest successful release/v2 runs: [CI](https://github.com/homarr-labs/homarr/actions/runs/34498613147) and [Workshop](https://github.com/homarr-labs/homarr/actions/runs/34498613171).
| Work | Observed time | Decision |
| ------------------------------- | ---------------------------------------------------------- | ---------------------------------------------------------------------------------------------- |
| Fast gate | 10m41s total; 6m47s in `pnpm test` | Remove automatic suite execution |
| Container and E2E | 2m55s total; 1m35s executing E2E | Remove test job setup, image pull, browsers, tests, and artifacts; retain fallback image build |
| Workshop validation | 4m29s total; 1m57s integration script + 1m27s image script | Remove both automatic scripts and duplicate Node install/typechecks |
| Custom Widget bundle validation | 25s including a direct Next.js build | Keep the checker; use the Turbo build task so the preceding affected build can be reused |
| Homarr image builds | 5m55s amd64, 4m33s arm64 | Keep both architecture builds and manifest verification |
The container job started at 16:44:44 UTC after the amd64 build completed at 16:10:21 UTC: a 34m23s scheduling/dependency gap in this run. Publication no longer waits for that job. These observed durations do not predict the next run's duration or prove that all delays came from tests.
## Pipeline decisions
| Entry point | Final behavior |
| --------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------- |
| Main CI: PR, push, merge queue, manual validate/publish | Lint, typecheck, affected builds, workspace checks, OpenAPI, Custom Widget architecture/bundle validation; no unit/integration/E2E execution |
| Homarr preview publication | Fast gate plus both image digests; no browser gate |
| Container fallback / local Act docker mode | Build amd64 only when no preview digest is available; do not rerun a failed preview build |
| Workshop PR/push workflow | Filter on all current Docker COPY inputs, root dependency/build configuration, and workflow changes; validate Compose; retain branch-only image publication |
| Formatting | Keep changed-file formatting; no test phase |
| Renovate / prebuilt Debian validators | Keep existing path-scoped config/native-artifact checks; no general test suite |
| Release / docs deployments | Keep release validation, builds, extraction, and publication; no hidden unit/E2E invocation found |
| Bot / translation / contributor / OpenAPI maintenance workflows | No test runner invocation found |
| `pnpm test`, `pnpm test:ui` | Unit and contract projects only; exclude Docker integration, E2E, and screenshots; no automatic retries or coverage |
| `pnpm test:coverage` | Explicit unit/contract coverage |
| `pnpm test:integration` | Ten Docker service/database files, bounded to two workers |
| `pnpm test:e2e` | Explicit E2E project, run once with two workers, no screenshot generator |
| `pnpm test:docs-screenshots` | Explicit documentation asset generation |
| Workshop integration/image/live-provider scripts | Retain as manual tools; no automatic CI execution |
| Custom Widget AI / Assistant / reference-API scripts | Retain existing opt-in tools; no automatic CI invocation |
| Developer CLI Go tests / benchmark specs | Retain manual behavioral coverage; no automatic CI invocation found |
Workshop path filters cover `apps/workshop`, `apps/docs`, `packages/custom-widgets`, `packages/definitions`, `packages/workshop`, `tooling/typescript`, patches, package/lock/workspace/Turbo/npm configuration, and the workflow itself. Revisit these filters whenever the Workshop Dockerfile adds a build input. Fast gate still validates the TypeScript workspaces.
## Test decisions
- Delete source-string UI tests for breadcrumbs and legacy Custom Widget migration, and the source-string portions of Workshop install, Copy AI prompt, and preview-panel suites. Keep actual URL resolution, prompt-size, rendering, and error-isolation checks.
- Delete translation tests pinning exact English copy or the absence of removed modal keys. Remove prose/style assertions from authoring resources while retaining bundle synchronization, payload budgets, catalog lookup, and JSX validation.
- Trim Workshop's static contract script to its real HTML rendering/escaping assertions. Remove regex/string checks for Go/JS source shape, deleted artifacts, and an assertion that prohibited path-filtering the workflow.
- Delete the home-page status-200 smoke test. Retained browser flows exercise navigation, and container startup already waits for readiness.
- Delete the unconditionally skipped Custom JSX workbench scenario and its unused mock server, plus the skipped onboarding restore scenario and its archive builder. They supplied no running coverage. Backup API and database migration suites remain.
- Retain active E2E onboarding/auth/access checks, LDAP, health endpoints including external Redis, lazy-widget hydration/navigation, management layouts, media layouts, and Assistant management as explicit manual scenarios. Their behavioral assertions can detect failures; lack of historical regression evidence is not proof they are useless.
- Retain authorization, secrets/redaction, MCP/API access, migration/rollback, persistence, request policy, widget runtime, layout algorithms, and mocked integration contract tests. Remove them from mandatory CI, not from developer tooling.
- Move seven live-service suites and MySQL/PostgreSQL migration suites out of default unit discovery. Add the previously uncollected `packages/db/proxy-reader.integration.spec.ts` to integration discovery. Keep in-memory SQLite contract tests in the default unit projects.
## Validation scope
Validate workflow syntax and dependencies with actionlint; check formatting and focused lint; enumerate project files without starting containers; run only the four modified unit test files and the trimmed standalone Workshop contract script. Full unit suites, Docker builds, browser scenarios, and live-provider tests are intentionally outside this audit's local validation. New CI timings must be measured after the PR runs.
Validation completed: actionlint and focused oxlint passed; all changed files passed formatting; 12 tests passed across the four changed unit files; the trimmed Workshop rendering script passed. File discovery returned 423 unique files with no cross-project duplicates: 405 unit/contract files, 10 Docker integration files, seven E2E files, and one screenshot generator.
+34
View File
@@ -2,6 +2,20 @@ import react from "@vitejs/plugin-react";
import tsconfigPaths from "vite-tsconfig-paths";
import { configDefaults, defineConfig } from "vitest/config";
// Docker-backed suites are opt-in through pnpm test:integration.
const integrationTests = [
"**/*.integration.spec.ts",
"packages/db/test/mysql-migration.spec.ts",
"packages/db/test/postgresql-migration.spec.ts",
"packages/integrations/test/aria2.spec.ts",
"packages/integrations/test/home-assistant.spec.ts",
"packages/integrations/test/nextcloud.spec.ts",
"packages/integrations/test/nzbget.spec.ts",
"packages/integrations/test/pi-hole.spec.ts",
"packages/integrations/test/sabnzbd.spec.ts",
"packages/integrations/test/technitium.spec.ts",
];
export default defineConfig({
plugins: [react(), tsconfigPaths()],
test: {
@@ -33,6 +47,7 @@ export default defineConfig({
environment: "node",
setupFiles: ["./vitest.setup.ts", "./vitest.setup.node.ts"],
include: ["packages/db/test/**/*.spec.ts"],
exclude: [...configDefaults.exclude, ...integrationTests],
},
},
{
@@ -77,15 +92,34 @@ export default defineConfig({
"packages/docker/**",
"packages/request-handler/**",
"e2e/**",
...integrationTests,
],
},
},
{
extends: true,
test: {
name: "integration",
environment: "node",
setupFiles: ["./vitest.setup.ts", "./vitest.setup.node.ts"],
include: integrationTests,
},
},
{
extends: true,
test: {
name: "e2e",
environment: "node",
include: ["e2e/**/*.spec.ts"],
exclude: [...configDefaults.exclude, "e2e/assistant-docs-screenshots.spec.ts"],
},
},
{
extends: true,
test: {
name: "docs-screenshots",
environment: "node",
include: ["e2e/assistant-docs-screenshots.spec.ts"],
},
},
],