ci: remove automatic test gates and trim test suites (#6816)
* ci: remove automatic test gates and trim test suites * docs: remove CI audit report
This commit is contained in:
1 parent
3358b6337f
commit
f994d665d2
19 files changed
+107
-820
No files matched your search
+10
-57
@@ -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' &&
|
||||
(
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
-36
@@ -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,");
|
||||
});
|
||||
});
|
||||
-11
@@ -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();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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");
|
||||
@@ -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();
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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
@@ -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;
|
||||
|
||||
@@ -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
@@ -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);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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"],
|
||||
},
|
||||
},
|
||||
],
|
||||
|
||||
Reference in new issue
Block a user