Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2a4658d255 | ||
|
|
ac62e5bfd6 | ||
|
|
f57a660088 |
No files matched your search
@@ -85,8 +85,11 @@ To do this, enter the board settings:
|
|||||||
|
|
||||||

|

|
||||||
|
|
||||||
Scroll to the **Custom CSS** section on the board settings page. It is part of the unified settings form; enter your CSS
|
Administrators can add instance-wide CSS under **Management → Settings → Branding → Custom CSS**. Enter CSS directly
|
||||||
there and save the board settings to apply it to that board.
|
or import a community theme from the Workshop, then save the server settings to apply it to every page.
|
||||||
|
|
||||||
|
For board-specific styling, scroll to the **Custom CSS** section on the board settings page. Enter your CSS there and
|
||||||
|
save the board settings to apply it to that board. Board CSS is loaded after the global stylesheet and can override it.
|
||||||
|
|
||||||

|

|
||||||
|
|
||||||
|
|||||||
@@ -142,6 +142,10 @@ Branding settings let administrators adapt Homarr to the identity and visual sty
|
|||||||
|
|
||||||
Choose the primary and secondary colors used by the theme. **Lock primary color** prevents users from overriding the configured primary color. **Default corner style** controls the global radius used by cards, controls, and other interface elements.
|
Choose the primary and secondary colors used by the theme. **Lock primary color** prevents users from overriding the configured primary color. **Default corner style** controls the global radius used by cards, controls, and other interface elements.
|
||||||
|
|
||||||
|
### Global custom CSS
|
||||||
|
|
||||||
|
The branding settings include a CSS editor for styles that should apply across the entire instance. You can write CSS directly or import a community theme from the Workshop. CSS configured on an individual board takes precedence over the global stylesheet.
|
||||||
|
|
||||||
### Sign-in page
|
### Sign-in page
|
||||||
|
|
||||||
You can set a greeting and a background image for authentication pages. The overlay slider adjusts the contrast between the background and the sign-in content. The visibility controls independently show or hide the application name, logo, and greeting.
|
You can set a greeting and a background image for authentication pages. The overlay slider adjusts the contrast between the background and the sign-in content. The visibility controls independently show or hide the application name, logo, and greeting.
|
||||||
|
|||||||
@@ -106,12 +106,10 @@
|
|||||||
"next": "catalog:",
|
"next": "catalog:",
|
||||||
"postcss-preset-mantine": "catalog:",
|
"postcss-preset-mantine": "catalog:",
|
||||||
"posthog-js": "catalog:",
|
"posthog-js": "catalog:",
|
||||||
"prismjs": "catalog:",
|
|
||||||
"react": "catalog:",
|
"react": "catalog:",
|
||||||
"react-dom": "catalog:",
|
"react-dom": "catalog:",
|
||||||
"react-error-boundary": "catalog:",
|
"react-error-boundary": "catalog:",
|
||||||
"react-shiki": "catalog:",
|
"react-shiki": "catalog:",
|
||||||
"react-simple-code-editor": "catalog:",
|
|
||||||
"remark-breaks": "catalog:",
|
"remark-breaks": "catalog:",
|
||||||
"remark-directive": "catalog:",
|
"remark-directive": "catalog:",
|
||||||
"remark-gfm": "catalog:",
|
"remark-gfm": "catalog:",
|
||||||
@@ -128,7 +126,6 @@
|
|||||||
"@types/better-sqlite3": "catalog:",
|
"@types/better-sqlite3": "catalog:",
|
||||||
"@types/chroma-js": "catalog:",
|
"@types/chroma-js": "catalog:",
|
||||||
"@types/node": "catalog:",
|
"@types/node": "catalog:",
|
||||||
"@types/prismjs": "catalog:",
|
|
||||||
"@types/react": "catalog:",
|
"@types/react": "catalog:",
|
||||||
"@types/react-dom": "catalog:",
|
"@types/react-dom": "catalog:",
|
||||||
"concurrently": "catalog:",
|
"concurrently": "catalog:",
|
||||||
|
|||||||
@@ -1,21 +1,16 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { Alert, Input } from "@mantine/core";
|
import { Alert } from "@mantine/core";
|
||||||
import { highlight, languages } from "prismjs";
|
|
||||||
import Editor from "react-simple-code-editor";
|
|
||||||
|
|
||||||
import "~/styles/prismjs.scss";
|
|
||||||
|
|
||||||
import { IconInfoCircle } from "@tabler/icons-react";
|
import { IconInfoCircle } from "@tabler/icons-react";
|
||||||
|
|
||||||
import { useSession } from "@homarr/auth/client";
|
import { useSession } from "@homarr/auth/client";
|
||||||
import type { UseFormReturnType } from "@homarr/form";
|
import type { UseFormReturnType } from "@homarr/form";
|
||||||
import { useI18n } from "@homarr/translation/client";
|
import { useI18n } from "@homarr/translation/client";
|
||||||
|
|
||||||
|
import { CustomCssEditor } from "~/components/custom-css/custom-css-editor";
|
||||||
import { SectionCard } from "~/components/manage/section-card";
|
import { SectionCard } from "~/components/manage/section-card";
|
||||||
import { WorkshopCssImportButton } from "~/components/workshop/workshop-css-import-button";
|
import { WorkshopCssImportButton } from "~/components/workshop/workshop-css-import-button";
|
||||||
import type { FormValues } from "./_settings-form";
|
import type { FormValues } from "./_settings-form";
|
||||||
import classes from "./customcss.module.css";
|
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
form: UseFormReturnType<FormValues>;
|
form: UseFormReturnType<FormValues>;
|
||||||
@@ -29,7 +24,12 @@ export const CustomCssSettingsContent = ({ form }: Props) => {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<SectionCard title={t("setting.section.customCss.title")}>
|
<SectionCard title={t("setting.section.customCss.title")}>
|
||||||
<CustomCssInput {...form.getInputProps("customCss")} />
|
<CustomCssEditor
|
||||||
|
id="board-custom-css"
|
||||||
|
label={customCssT("label")}
|
||||||
|
description={customCssT("description")}
|
||||||
|
{...form.getInputProps("customCss")}
|
||||||
|
/>
|
||||||
|
|
||||||
<Alert variant="light" color="cyan" title={customCssT("customClassesAlert.title")} icon={<IconInfoCircle />}>
|
<Alert variant="light" color="cyan" title={customCssT("customClassesAlert.title")} icon={<IconInfoCircle />}>
|
||||||
{customCssT("customClassesAlert.description")}
|
{customCssT("customClassesAlert.description")}
|
||||||
@@ -38,40 +38,3 @@ export const CustomCssSettingsContent = ({ form }: Props) => {
|
|||||||
</SectionCard>
|
</SectionCard>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
interface CustomCssInputProps {
|
|
||||||
value?: string;
|
|
||||||
onChange: (value: string) => void;
|
|
||||||
error?: React.ReactNode;
|
|
||||||
}
|
|
||||||
|
|
||||||
const CustomCssInput = ({ value, onChange, error }: CustomCssInputProps) => {
|
|
||||||
const customCssT = useI18n("board.field.customCss");
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Input.Wrapper
|
|
||||||
label={customCssT("label")}
|
|
||||||
labelProps={{
|
|
||||||
htmlFor: "custom-css",
|
|
||||||
}}
|
|
||||||
description={customCssT("description")}
|
|
||||||
error={error}
|
|
||||||
inputWrapperOrder={["label", "description", "input", "error"]}
|
|
||||||
>
|
|
||||||
<div className={classes.codeEditorRoot}>
|
|
||||||
<Editor
|
|
||||||
textareaId="custom-css"
|
|
||||||
onValueChange={onChange}
|
|
||||||
value={value ?? ""}
|
|
||||||
highlight={(code) => highlight(code, languages.extend("css", {}), "css")}
|
|
||||||
padding={10}
|
|
||||||
style={{
|
|
||||||
fontFamily: '"Fira code", "Fira Mono", monospace',
|
|
||||||
fontSize: 12,
|
|
||||||
minHeight: 250,
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</Input.Wrapper>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -1,22 +0,0 @@
|
|||||||
.codeEditorFooter {
|
|
||||||
border-bottom-left-radius: var(--mantine-radius-sm);
|
|
||||||
border-bottom-right-radius: var(--mantine-radius-sm);
|
|
||||||
background-color: light-dark(var(--mantine-color-gray-1), var(--mantine-color-dark-7));
|
|
||||||
}
|
|
||||||
|
|
||||||
.codeEditorRoot {
|
|
||||||
margin-top: 4px;
|
|
||||||
border-color: light-dark(var(--mantine-color-gray-4), var(--mantine-color-dark-4));
|
|
||||||
border-width: 1px;
|
|
||||||
border-style: solid;
|
|
||||||
border-radius: var(--mantine-radius-sm);
|
|
||||||
}
|
|
||||||
|
|
||||||
.codeEditor {
|
|
||||||
background-color: light-dark(white, var(--mantine-color-dark-6));
|
|
||||||
font-size: var(--mantine-font-size-xs);
|
|
||||||
}
|
|
||||||
|
|
||||||
.codeEditor ::placeholder {
|
|
||||||
color: light-dark(var(--mantine-color-gray-5), var(--mantine-color-dark-3));
|
|
||||||
}
|
|
||||||
@@ -199,6 +199,7 @@ export default async function Layout(props: {
|
|||||||
) : null}
|
) : null}
|
||||||
<SearchEngineOptimization />
|
<SearchEngineOptimization />
|
||||||
<CrowdinLiveTranslation locale={locale} />
|
<CrowdinLiveTranslation locale={locale} />
|
||||||
|
<style data-homarr-global-custom-css>{serverSettings.branding.customCss}</style>
|
||||||
</head>
|
</head>
|
||||||
<body className={[fontSans.className, fontSans.variable].join(" ")} suppressHydrationWarning>
|
<body className={[fontSans.className, fontSans.variable].join(" ")} suppressHydrationWarning>
|
||||||
<Analytics enabled={serverSettings.analytics.enableGeneral} />
|
<Analytics enabled={serverSettings.analytics.enableGeneral} />
|
||||||
|
|||||||
@@ -1,50 +1 @@
|
|||||||
"use client";
|
export { CodeEditor } from "~/components/custom-widgets/code-editor";
|
||||||
|
|
||||||
import { useMemo } from "react";
|
|
||||||
import type { ComponentProps } from "react";
|
|
||||||
|
|
||||||
import { CustomWidgetCodeEditor } from "@homarr/custom-widgets/workbench";
|
|
||||||
import { invariantTechnicalLabels } from "@homarr/definitions";
|
|
||||||
import { useI18n } from "@homarr/translation/client";
|
|
||||||
|
|
||||||
type SharedEditorProps = ComponentProps<typeof CustomWidgetCodeEditor>;
|
|
||||||
type CodeEditorProps = Omit<SharedEditorProps, "messages">;
|
|
||||||
|
|
||||||
export function CodeEditor(props: CodeEditorProps) {
|
|
||||||
const t = useI18n("customWidget.editor");
|
|
||||||
const tCommon = useI18n("common");
|
|
||||||
const messages = useMemo<SharedEditorProps["messages"]>(
|
|
||||||
() => ({
|
|
||||||
languageJsx: invariantTechnicalLabels.jsx,
|
|
||||||
languageJson: invariantTechnicalLabels.json,
|
|
||||||
undo: tCommon("action.undo"),
|
|
||||||
redo: t("action.redo"),
|
|
||||||
components: t("action.components"),
|
|
||||||
componentSearch: t("componentReference.search"),
|
|
||||||
componentEmpty: t("componentReference.empty"),
|
|
||||||
componentCount: (count) => t("componentReference.count", { count }),
|
|
||||||
insertStarter: t("action.insertStarter"),
|
|
||||||
format: t("action.format"),
|
|
||||||
copy: tCommon("action.copy"),
|
|
||||||
copied: t("action.copied"),
|
|
||||||
schema: t("action.schema"),
|
|
||||||
schemaTab: invariantTechnicalLabels.jsonSchema,
|
|
||||||
minimalTab: t("reference.minimal"),
|
|
||||||
fullTab: t("reference.full"),
|
|
||||||
errors: (count) => t("status.errors", { count }),
|
|
||||||
warnings: (count) => t("status.warnings", { count }),
|
|
||||||
ready: t("status.ready"),
|
|
||||||
position: (cursor) => t("status.position", cursor),
|
|
||||||
characters: (count, limit) =>
|
|
||||||
limit ? t("status.charactersWithLimit", { count, limit }) : t("status.characters", { count }),
|
|
||||||
diagnosticsTitle: t("diagnostics.title"),
|
|
||||||
diagnostic: (diagnostic) =>
|
|
||||||
`${diagnostic.line ? `${t("diagnostics.line", { line: diagnostic.line })}: ` : ""}${t(
|
|
||||||
`diagnostics.${diagnostic.code}` as never,
|
|
||||||
{ value: diagnostic.value ?? "" } as never,
|
|
||||||
)}`,
|
|
||||||
}),
|
|
||||||
[t, tCommon],
|
|
||||||
);
|
|
||||||
return <CustomWidgetCodeEditor {...props} messages={messages} />;
|
|
||||||
}
|
|
||||||
@@ -20,7 +20,9 @@ import type { UseFormReturnType } from "@homarr/form";
|
|||||||
import { useI18n } from "@homarr/translation/client";
|
import { useI18n } from "@homarr/translation/client";
|
||||||
import { BoardColorInput, CornerStylePicker, cornerStyleValues } from "@homarr/ui";
|
import { BoardColorInput, CornerStylePicker, cornerStyleValues } from "@homarr/ui";
|
||||||
|
|
||||||
|
import { CustomCssEditor } from "~/components/custom-css/custom-css-editor";
|
||||||
import { SectionCard } from "~/components/manage/section-card";
|
import { SectionCard } from "~/components/manage/section-card";
|
||||||
|
import { WorkshopCssImportButton } from "~/components/workshop/workshop-css-import-button";
|
||||||
import type { FormValues } from "./settings-form";
|
import type { FormValues } from "./settings-form";
|
||||||
|
|
||||||
interface BrandingSettingsFormProps {
|
interface BrandingSettingsFormProps {
|
||||||
@@ -101,6 +103,21 @@ export const BrandingSettingsForm = ({ form }: BrandingSettingsFormProps) => {
|
|||||||
</SimpleGrid>
|
</SimpleGrid>
|
||||||
</Fieldset>
|
</Fieldset>
|
||||||
|
|
||||||
|
<Fieldset legend={t("groups.customCss")} p="sm">
|
||||||
|
<CustomCssEditor
|
||||||
|
id="global-custom-css"
|
||||||
|
label={t("customCss.label")}
|
||||||
|
description={t("customCss.description")}
|
||||||
|
actions={
|
||||||
|
<WorkshopCssImportButton
|
||||||
|
size="compact-xs"
|
||||||
|
onImport={(css) => form.setFieldValue("branding.customCss", css)}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
{...form.getInputProps("branding.customCss")}
|
||||||
|
/>
|
||||||
|
</Fieldset>
|
||||||
|
|
||||||
<Fieldset legend={t("groups.authentication")} p="sm">
|
<Fieldset legend={t("groups.authentication")} p="sm">
|
||||||
<Stack gap="sm">
|
<Stack gap="sm">
|
||||||
<SimpleGrid cols={{ base: 1, md: 2 }} spacing="sm" verticalSpacing="sm">
|
<SimpleGrid cols={{ base: 1, md: 2 }} spacing="sm" verticalSpacing="sm">
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useEffect, useRef, useState } from "react";
|
import { useEffect, useRef, useState } from "react";
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
import { Button, Stack } from "@mantine/core";
|
import { Button, Stack } from "@mantine/core";
|
||||||
import { z } from "zod/v4";
|
import { z } from "zod/v4";
|
||||||
|
|
||||||
@@ -70,6 +71,7 @@ interface SettingsFormProps {
|
|||||||
export const SettingsForm = ({ initialData, selectableBoards, selectableSearchEngines }: SettingsFormProps) => {
|
export const SettingsForm = ({ initialData, selectableBoards, selectableSearchEngines }: SettingsFormProps) => {
|
||||||
const tCommon = useI18n("common");
|
const tCommon = useI18n("common");
|
||||||
const tSettings = useI18n("management.page.settings");
|
const tSettings = useI18n("management.page.settings");
|
||||||
|
const router = useRouter();
|
||||||
|
|
||||||
const initialValues = buildInitialValues(initialData);
|
const initialValues = buildInitialValues(initialData);
|
||||||
const initialValuesRef = useRef(initialValues);
|
const initialValuesRef = useRef(initialValues);
|
||||||
@@ -164,6 +166,7 @@ export const SettingsForm = ({ initialData, selectableBoards, selectableSearchEn
|
|||||||
form.setInitialValues(values);
|
form.setInitialValues(values);
|
||||||
form.resetDirty();
|
form.resetDirty();
|
||||||
await revalidatePathActionAsync("/manage/settings");
|
await revalidatePathActionAsync("/manage/settings");
|
||||||
|
router.refresh();
|
||||||
showSuccessNotification({
|
showSuccessNotification({
|
||||||
title: tCommon("notification.update.success"),
|
title: tCommon("notification.update.success"),
|
||||||
message: tSettings("notification.success.message"),
|
message: tSettings("notification.success.message"),
|
||||||
|
|||||||
@@ -0,0 +1,38 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { CodeEditor } from "~/components/custom-widgets/code-editor";
|
||||||
|
|
||||||
|
interface CustomCssEditorProps {
|
||||||
|
id: string;
|
||||||
|
label: string;
|
||||||
|
description: string;
|
||||||
|
value?: string;
|
||||||
|
onChange: (value: string) => void;
|
||||||
|
error?: React.ReactNode;
|
||||||
|
actions?: React.ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const CustomCssEditor = ({
|
||||||
|
id,
|
||||||
|
label,
|
||||||
|
description,
|
||||||
|
value = "",
|
||||||
|
onChange,
|
||||||
|
error,
|
||||||
|
actions,
|
||||||
|
}: CustomCssEditorProps) => {
|
||||||
|
return (
|
||||||
|
<CodeEditor
|
||||||
|
id={id}
|
||||||
|
label={label}
|
||||||
|
description={description}
|
||||||
|
value={value}
|
||||||
|
onChange={onChange}
|
||||||
|
error={error}
|
||||||
|
language="css"
|
||||||
|
height="250px"
|
||||||
|
maxLength={16_384}
|
||||||
|
actions={actions}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useMemo } from "react";
|
||||||
|
import type { ComponentProps } from "react";
|
||||||
|
|
||||||
|
import { CustomWidgetCodeEditor } from "@homarr/custom-widgets/workbench";
|
||||||
|
import { invariantTechnicalLabels } from "@homarr/definitions";
|
||||||
|
import { useI18n } from "@homarr/translation/client";
|
||||||
|
|
||||||
|
type SharedEditorProps = ComponentProps<typeof CustomWidgetCodeEditor>;
|
||||||
|
type CodeEditorProps = Omit<SharedEditorProps, "messages">;
|
||||||
|
|
||||||
|
export function CodeEditor(props: CodeEditorProps) {
|
||||||
|
const t = useI18n("customWidget.editor");
|
||||||
|
const tCommon = useI18n("common");
|
||||||
|
const messages = useMemo<SharedEditorProps["messages"]>(
|
||||||
|
() => ({
|
||||||
|
languageJsx: invariantTechnicalLabels.jsx,
|
||||||
|
languageJson: invariantTechnicalLabels.json,
|
||||||
|
undo: tCommon("action.undo"),
|
||||||
|
redo: t("action.redo"),
|
||||||
|
components: t("action.components"),
|
||||||
|
componentSearch: t("componentReference.search"),
|
||||||
|
componentEmpty: t("componentReference.empty"),
|
||||||
|
componentCount: (count) => t("componentReference.count", { count }),
|
||||||
|
insertStarter: t("action.insertStarter"),
|
||||||
|
format: t("action.format"),
|
||||||
|
copy: tCommon("action.copy"),
|
||||||
|
copied: t("action.copied"),
|
||||||
|
schema: t("action.schema"),
|
||||||
|
schemaTab: invariantTechnicalLabels.jsonSchema,
|
||||||
|
minimalTab: t("reference.minimal"),
|
||||||
|
fullTab: t("reference.full"),
|
||||||
|
errors: (count) => t("status.errors", { count }),
|
||||||
|
warnings: (count) => t("status.warnings", { count }),
|
||||||
|
ready: t("status.ready"),
|
||||||
|
position: (cursor) => t("status.position", cursor),
|
||||||
|
characters: (count, limit) =>
|
||||||
|
limit ? t("status.charactersWithLimit", { count, limit }) : t("status.characters", { count }),
|
||||||
|
diagnosticsTitle: t("diagnostics.title"),
|
||||||
|
diagnostic: (diagnostic) =>
|
||||||
|
`${diagnostic.line ? `${t("diagnostics.line", { line: diagnostic.line })}: ` : ""}${t(
|
||||||
|
`diagnostics.${diagnostic.code}` as never,
|
||||||
|
{ value: diagnostic.value ?? "" } as never,
|
||||||
|
)}`,
|
||||||
|
}),
|
||||||
|
[t, tCommon],
|
||||||
|
);
|
||||||
|
return <CustomWidgetCodeEditor {...props} messages={messages} />;
|
||||||
|
}
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
|
import type { ButtonProps } from "@mantine/core";
|
||||||
import { ActionIcon, Button, Group, Loader, Modal, Pagination, Stack, Text, TextInput, Tooltip } from "@mantine/core";
|
import { ActionIcon, Button, Group, Loader, Modal, Pagination, Stack, Text, TextInput, Tooltip } from "@mantine/core";
|
||||||
import { useDebouncedValue, useDisclosure } from "@mantine/hooks";
|
import { useDebouncedValue, useDisclosure } from "@mantine/hooks";
|
||||||
import { IconDownload, IconExternalLink, IconSearch } from "@tabler/icons-react";
|
import { IconDownload, IconExternalLink, IconSearch } from "@tabler/icons-react";
|
||||||
@@ -21,15 +22,27 @@ import { WorkshopVoteControl } from "./workshop-vote-control";
|
|||||||
/**
|
/**
|
||||||
* Picks a community CSS theme in a single surface: browse, confirm, done.
|
* Picks a community CSS theme in a single surface: browse, confirm, done.
|
||||||
* The imported rules land in the editor below and are only persisted when the
|
* The imported rules land in the editor below and are only persisted when the
|
||||||
* board settings themselves are saved, so there is nothing to preview twice.
|
* settings themselves are saved, so there is nothing to preview twice.
|
||||||
*/
|
*/
|
||||||
export function WorkshopCssImportButton({ onImport }: { onImport(css: string): void }) {
|
export function WorkshopCssImportButton({
|
||||||
|
onImport,
|
||||||
|
size,
|
||||||
|
}: {
|
||||||
|
onImport(css: string): void;
|
||||||
|
size?: ButtonProps["size"];
|
||||||
|
}) {
|
||||||
const t = useI18n("workshop");
|
const t = useI18n("workshop");
|
||||||
const [opened, controls] = useDisclosure(false);
|
const [opened, controls] = useDisclosure(false);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Button type="button" variant="light" leftSection={<IconDownload size={16} />} onClick={controls.open}>
|
<Button
|
||||||
|
type="button"
|
||||||
|
size={size}
|
||||||
|
variant="light"
|
||||||
|
leftSection={<IconDownload size={16} />}
|
||||||
|
onClick={controls.open}
|
||||||
|
>
|
||||||
{t("importCss")}
|
{t("importCss")}
|
||||||
</Button>
|
</Button>
|
||||||
<Modal opened={opened} onClose={controls.close} title={t("importCss")} size={modalSizeSelect} radius="md">
|
<Modal opened={opened} onClose={controls.close} title={t("importCss")} size={modalSizeSelect} radius="md">
|
||||||
|
|||||||
@@ -1,225 +0,0 @@
|
|||||||
[data-mantine-color-scheme="light"] {
|
|
||||||
code[class*="language-"],
|
|
||||||
pre[class*="language-"] {
|
|
||||||
color: #000;
|
|
||||||
background: 0 0;
|
|
||||||
text-shadow: 0 1px #fff;
|
|
||||||
font-family: Consolas, Monaco, "Andale Mono", "Ubuntu Mono", monospace;
|
|
||||||
font-size: 1em;
|
|
||||||
text-align: left;
|
|
||||||
white-space: pre;
|
|
||||||
word-spacing: normal;
|
|
||||||
word-break: normal;
|
|
||||||
word-wrap: normal;
|
|
||||||
line-height: 1.5;
|
|
||||||
-moz-tab-size: 4;
|
|
||||||
-o-tab-size: 4;
|
|
||||||
tab-size: 4;
|
|
||||||
-webkit-hyphens: none;
|
|
||||||
-moz-hyphens: none;
|
|
||||||
-ms-hyphens: none;
|
|
||||||
hyphens: none;
|
|
||||||
}
|
|
||||||
code[class*="language-"] ::-moz-selection,
|
|
||||||
code[class*="language-"]::-moz-selection,
|
|
||||||
pre[class*="language-"] ::-moz-selection,
|
|
||||||
pre[class*="language-"]::-moz-selection {
|
|
||||||
text-shadow: none;
|
|
||||||
background: #b3d4fc;
|
|
||||||
}
|
|
||||||
code[class*="language-"] ::selection,
|
|
||||||
code[class*="language-"]::selection,
|
|
||||||
pre[class*="language-"] ::selection,
|
|
||||||
pre[class*="language-"]::selection {
|
|
||||||
text-shadow: none;
|
|
||||||
background: #b3d4fc;
|
|
||||||
}
|
|
||||||
@media print {
|
|
||||||
code[class*="language-"],
|
|
||||||
pre[class*="language-"] {
|
|
||||||
text-shadow: none;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
pre[class*="language-"] {
|
|
||||||
padding: 1em;
|
|
||||||
margin: 0.5em 0;
|
|
||||||
overflow: auto;
|
|
||||||
}
|
|
||||||
:not(pre) > code[class*="language-"],
|
|
||||||
pre[class*="language-"] {
|
|
||||||
background: #f5f2f0;
|
|
||||||
}
|
|
||||||
:not(pre) > code[class*="language-"] {
|
|
||||||
padding: 0.1em;
|
|
||||||
border-radius: 0.3em;
|
|
||||||
white-space: normal;
|
|
||||||
}
|
|
||||||
.token.cdata,
|
|
||||||
.token.comment,
|
|
||||||
.token.doctype,
|
|
||||||
.token.prolog {
|
|
||||||
color: #708090;
|
|
||||||
}
|
|
||||||
.token.punctuation {
|
|
||||||
color: #999;
|
|
||||||
}
|
|
||||||
.token.namespace {
|
|
||||||
opacity: 0.7;
|
|
||||||
}
|
|
||||||
.token.boolean,
|
|
||||||
.token.constant,
|
|
||||||
.token.deleted,
|
|
||||||
.token.number,
|
|
||||||
.token.property,
|
|
||||||
.token.symbol,
|
|
||||||
.token.tag {
|
|
||||||
color: #905;
|
|
||||||
}
|
|
||||||
.token.attr-name,
|
|
||||||
.token.builtin,
|
|
||||||
.token.char,
|
|
||||||
.token.inserted,
|
|
||||||
.token.selector,
|
|
||||||
.token.string {
|
|
||||||
color: #690;
|
|
||||||
}
|
|
||||||
.language-css .token.string,
|
|
||||||
.style .token.string,
|
|
||||||
.token.entity,
|
|
||||||
.token.operator,
|
|
||||||
.token.url {
|
|
||||||
color: #9a6e3a;
|
|
||||||
background: hsla(0, 0%, 100%, 0.5);
|
|
||||||
}
|
|
||||||
.token.atrule,
|
|
||||||
.token.attr-value,
|
|
||||||
.token.keyword {
|
|
||||||
color: #07a;
|
|
||||||
}
|
|
||||||
.token.class-name,
|
|
||||||
.token.function {
|
|
||||||
color: #dd4a68;
|
|
||||||
}
|
|
||||||
.token.important,
|
|
||||||
.token.regex,
|
|
||||||
.token.variable {
|
|
||||||
color: #e90;
|
|
||||||
}
|
|
||||||
.token.bold,
|
|
||||||
.token.important {
|
|
||||||
font-weight: 700;
|
|
||||||
}
|
|
||||||
.token.italic {
|
|
||||||
font-style: italic;
|
|
||||||
}
|
|
||||||
.token.entity {
|
|
||||||
cursor: help;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
[data-mantine-color-scheme="dark"] {
|
|
||||||
code[class*="language-"],
|
|
||||||
pre[class*="language-"] {
|
|
||||||
color: #fff;
|
|
||||||
background: 0 0;
|
|
||||||
text-shadow: 0 -0.1em 0.2em #000;
|
|
||||||
font-family: Consolas, Monaco, "Andale Mono", "Ubuntu Mono", monospace;
|
|
||||||
font-size: 1em;
|
|
||||||
text-align: left;
|
|
||||||
white-space: pre;
|
|
||||||
word-spacing: normal;
|
|
||||||
word-break: normal;
|
|
||||||
word-wrap: normal;
|
|
||||||
line-height: 1.5;
|
|
||||||
-moz-tab-size: 4;
|
|
||||||
-o-tab-size: 4;
|
|
||||||
tab-size: 4;
|
|
||||||
-webkit-hyphens: none;
|
|
||||||
-moz-hyphens: none;
|
|
||||||
-ms-hyphens: none;
|
|
||||||
hyphens: none;
|
|
||||||
}
|
|
||||||
@media print {
|
|
||||||
code[class*="language-"],
|
|
||||||
pre[class*="language-"] {
|
|
||||||
text-shadow: none;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
:not(pre) > code[class*="language-"],
|
|
||||||
pre[class*="language-"] {
|
|
||||||
background: #4c3f33;
|
|
||||||
}
|
|
||||||
pre[class*="language-"] {
|
|
||||||
padding: 1em;
|
|
||||||
margin: 0.5em 0;
|
|
||||||
overflow: auto;
|
|
||||||
border: 0.3em solid #7a6651;
|
|
||||||
border-radius: 0.5em;
|
|
||||||
box-shadow: 1px 1px 0.5em #000 inset;
|
|
||||||
}
|
|
||||||
:not(pre) > code[class*="language-"] {
|
|
||||||
padding: 0.15em 0.2em 0.05em;
|
|
||||||
border-radius: 0.3em;
|
|
||||||
border: 0.13em solid #7a6651;
|
|
||||||
box-shadow: 1px 1px 0.3em -0.1em #000 inset;
|
|
||||||
white-space: normal;
|
|
||||||
}
|
|
||||||
.token.cdata,
|
|
||||||
.token.comment,
|
|
||||||
.token.doctype,
|
|
||||||
.token.prolog {
|
|
||||||
color: #997f66;
|
|
||||||
}
|
|
||||||
.token.punctuation {
|
|
||||||
opacity: 0.7;
|
|
||||||
}
|
|
||||||
.token.namespace {
|
|
||||||
opacity: 0.7;
|
|
||||||
}
|
|
||||||
.token.boolean,
|
|
||||||
.token.constant,
|
|
||||||
.token.number,
|
|
||||||
.token.property,
|
|
||||||
.token.symbol,
|
|
||||||
.token.tag {
|
|
||||||
color: #d1939e;
|
|
||||||
}
|
|
||||||
.token.attr-name,
|
|
||||||
.token.builtin,
|
|
||||||
.token.char,
|
|
||||||
.token.inserted,
|
|
||||||
.token.selector,
|
|
||||||
.token.string {
|
|
||||||
color: #bce051;
|
|
||||||
}
|
|
||||||
.language-css .token.string,
|
|
||||||
.style .token.string,
|
|
||||||
.token.entity,
|
|
||||||
.token.operator,
|
|
||||||
.token.url,
|
|
||||||
.token.variable {
|
|
||||||
color: #f4b73d;
|
|
||||||
}
|
|
||||||
.token.atrule,
|
|
||||||
.token.attr-value,
|
|
||||||
.token.keyword {
|
|
||||||
color: #d1939e;
|
|
||||||
}
|
|
||||||
.token.important,
|
|
||||||
.token.regex {
|
|
||||||
color: #e90;
|
|
||||||
}
|
|
||||||
.token.bold,
|
|
||||||
.token.important {
|
|
||||||
font-weight: 700;
|
|
||||||
}
|
|
||||||
.token.italic {
|
|
||||||
font-style: italic;
|
|
||||||
}
|
|
||||||
.token.entity {
|
|
||||||
cursor: help;
|
|
||||||
}
|
|
||||||
.token.deleted {
|
|
||||||
color: red;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -44,6 +44,8 @@
|
|||||||
"lint:fix": "turbo lint --continue -- --fix",
|
"lint:fix": "turbo lint --continue -- --fix",
|
||||||
"lint:ws": "pnpm dlx sherif@latest",
|
"lint:ws": "pnpm dlx sherif@latest",
|
||||||
"qa:browser-agent:ux": "bash scripts/browser-agent/ux-coherence-happy-paths.sh",
|
"qa:browser-agent:ux": "bash scripts/browser-agent/ux-coherence-happy-paths.sh",
|
||||||
|
"qa:release-v2:fixtures": "tsx scripts/release-v2-qa/fixture-server.mts",
|
||||||
|
"qa:release-v2:seed": "tsx scripts/release-v2-qa/seed.mts",
|
||||||
"release": "semantic-release",
|
"release": "semantic-release",
|
||||||
"scripts:update-bug-report-template": "tsx ./scripts/update-bug-report-template.mts",
|
"scripts:update-bug-report-template": "tsx ./scripts/update-bug-report-template.mts",
|
||||||
"scripts:update-readme-integrations": "tsx ./scripts/update-integration-list.mts",
|
"scripts:update-readme-integrations": "tsx ./scripts/update-integration-list.mts",
|
||||||
|
|||||||
@@ -54,6 +54,7 @@ export function CodeEditorToolbar({
|
|||||||
</Text>
|
</Text>
|
||||||
</Group>
|
</Group>
|
||||||
<Group gap={6}>
|
<Group gap={6}>
|
||||||
|
{props.actions}
|
||||||
{!props.readOnly && (
|
{!props.readOnly && (
|
||||||
<>
|
<>
|
||||||
<Button
|
<Button
|
||||||
|
|||||||
@@ -57,4 +57,5 @@ export interface CustomWidgetCodeEditorProps {
|
|||||||
readOnly?: boolean;
|
readOnly?: boolean;
|
||||||
reference?: CustomWidgetSchemaReferenceData;
|
reference?: CustomWidgetSchemaReferenceData;
|
||||||
height?: string;
|
height?: string;
|
||||||
|
actions?: ReactNode;
|
||||||
}
|
}
|
||||||
@@ -51,6 +51,7 @@ export const brandingServerSettingsSchema = z.object({
|
|||||||
signInBackgroundOverlay: z.number().min(0).max(0.9),
|
signInBackgroundOverlay: z.number().min(0).max(0.9),
|
||||||
authBranding: authBrandingSchema,
|
authBranding: authBrandingSchema,
|
||||||
defaultRadius: z.enum(brandingRadiusOptions),
|
defaultRadius: z.enum(brandingRadiusOptions),
|
||||||
|
customCss: z.string().max(16_384),
|
||||||
});
|
});
|
||||||
|
|
||||||
export type BrandingSettings = z.infer<typeof brandingServerSettingsSchema>;
|
export type BrandingSettings = z.infer<typeof brandingServerSettingsSchema>;
|
||||||
@@ -71,6 +72,7 @@ export const defaultBrandingSettings: BrandingSettings = {
|
|||||||
showGreeting: true,
|
showGreeting: true,
|
||||||
},
|
},
|
||||||
defaultRadius: "md",
|
defaultRadius: "md",
|
||||||
|
customCss: "",
|
||||||
};
|
};
|
||||||
|
|
||||||
const hasDefaultBrandingColors = (branding: Pick<BrandingSettings, "primaryColor" | "secondaryColor">) =>
|
const hasDefaultBrandingColors = (branding: Pick<BrandingSettings, "primaryColor" | "secondaryColor">) =>
|
||||||
|
|||||||
@@ -5972,6 +5972,7 @@
|
|||||||
"groups": {
|
"groups": {
|
||||||
"identity": "Identity",
|
"identity": "Identity",
|
||||||
"appearance": "Appearance",
|
"appearance": "Appearance",
|
||||||
|
"customCss": "Custom CSS",
|
||||||
"authentication": "Authentication"
|
"authentication": "Authentication"
|
||||||
},
|
},
|
||||||
"identity": {
|
"identity": {
|
||||||
@@ -6018,6 +6019,10 @@
|
|||||||
"xl": "Soft"
|
"xl": "Soft"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"customCss": {
|
||||||
|
"label": "Global custom CSS",
|
||||||
|
"description": "Applied to every page. Board custom CSS can override it."
|
||||||
|
},
|
||||||
"authVisibility": {
|
"authVisibility": {
|
||||||
"description": "Choose what appears on every authentication page.",
|
"description": "Choose what appears on every authentication page.",
|
||||||
"appName": "Application name",
|
"appName": "Application name",
|
||||||
|
|||||||
@@ -236,23 +236,26 @@ const SystemCharts = ({
|
|||||||
const labelDisplayMode = isAdvanced ? "textWithIcon" : options.labelDisplayMode;
|
const labelDisplayMode = isAdvanced ? "textWithIcon" : options.labelDisplayMode;
|
||||||
const chartCount = visibleCharts.length;
|
const chartCount = visibleCharts.length;
|
||||||
const chartColumns = useMemo(() => (isAdvanced ? getAdvancedChartColumns(width) : 1), [isAdvanced, width]);
|
const chartColumns = useMemo(() => (isAdvanced ? getAdvancedChartColumns(width) : 1), [isAdvanced, width]);
|
||||||
const compactChartGap = 8;
|
const chartGap = isAdvanced ? "xs" : 8;
|
||||||
const compactRowHeight = 56;
|
|
||||||
const chartHeight = isAdvanced
|
|
||||||
? 110
|
|
||||||
: Math.max(
|
|
||||||
28,
|
|
||||||
Math.min(compactRowHeight, (height - Math.max(0, chartCount - 1) * compactChartGap) / Math.max(1, chartCount)),
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Stack gap={isAdvanced ? "xs" : compactChartGap} h="auto" miw={0}>
|
// style={{height}}, not h={height} or h={`${height}px`} - Mantine's h prop runs numbers
|
||||||
|
// (and even pixel *strings*, since its rem() converter strips a "px" suffix before checking
|
||||||
|
// whether the remainder is a parseable number) through rem() * var(--mantine-scale), which
|
||||||
|
// silently rescales the value again on top of the board's own zoom. Only calc()/percentage/
|
||||||
|
// non-numeric strings skip that path (why h="100%" below is safe) - a literal style.height
|
||||||
|
// is the only way to set an exact pixel value that isn't run through it.
|
||||||
|
<Stack gap={chartGap} style={{ height: `${height}px` }} miw={0}>
|
||||||
{showTitle && (
|
{showTitle && (
|
||||||
<Text size="sm" fw={600} truncate="end">
|
<Text size="sm" fw={600} truncate="end">
|
||||||
{integrationName}
|
{integrationName}
|
||||||
</Text>
|
</Text>
|
||||||
)}
|
)}
|
||||||
<SimpleGrid cols={chartColumns} spacing={isAdvanced ? "xs" : compactChartGap}>
|
{/* Divide the available height evenly across however many rows chartColumns wraps the
|
||||||
|
charts into via CSS grid-auto-rows: 1fr, rather than computing a per-chart pixel height
|
||||||
|
in JS - keeps every chart's own sizing untouched (still h="100%" of its grid cell) and
|
||||||
|
needs no board-zoom-aware math of its own. */}
|
||||||
|
<SimpleGrid cols={chartColumns} spacing={chartGap} style={{ flex: 1, minHeight: 0, gridAutoRows: "1fr" }}>
|
||||||
{chartCount === 0 && (
|
{chartCount === 0 && (
|
||||||
<Center h="100%">
|
<Center h="100%">
|
||||||
<Text size="sm" c="dimmed">
|
<Text size="sm" c="dimmed">
|
||||||
@@ -261,7 +264,7 @@ const SystemCharts = ({
|
|||||||
</Center>
|
</Center>
|
||||||
)}
|
)}
|
||||||
{visibleCharts.includes("cpu") && (
|
{visibleCharts.includes("cpu") && (
|
||||||
<Box h={chartHeight}>
|
<Box h="100%">
|
||||||
<SystemResourceCPUChart
|
<SystemResourceCPUChart
|
||||||
cpuUsageOverTime={items.map((item) => item.cpu)}
|
cpuUsageOverTime={items.map((item) => item.cpu)}
|
||||||
hasShadow={options.hasShadow}
|
hasShadow={options.hasShadow}
|
||||||
@@ -271,7 +274,7 @@ const SystemCharts = ({
|
|||||||
</Box>
|
</Box>
|
||||||
)}
|
)}
|
||||||
{visibleCharts.includes("memory") && (
|
{visibleCharts.includes("memory") && (
|
||||||
<Box h={chartHeight}>
|
<Box h="100%">
|
||||||
<SystemResourceMemoryChart
|
<SystemResourceMemoryChart
|
||||||
memoryUsageOverTime={items.map((item) => item.memory)}
|
memoryUsageOverTime={items.map((item) => item.memory)}
|
||||||
totalCapacityInBytes={memoryCapacityInBytes}
|
totalCapacityInBytes={memoryCapacityInBytes}
|
||||||
@@ -282,7 +285,7 @@ const SystemCharts = ({
|
|||||||
</Box>
|
</Box>
|
||||||
)}
|
)}
|
||||||
{visibleCharts.includes("gpu") && (
|
{visibleCharts.includes("gpu") && (
|
||||||
<Box h={chartHeight}>
|
<Box h="100%">
|
||||||
<SystemResourceGPUChart
|
<SystemResourceGPUChart
|
||||||
gpuUsageOverTime={items.map((item) => item.gpu)}
|
gpuUsageOverTime={items.map((item) => item.gpu)}
|
||||||
hasShadow={options.hasShadow}
|
hasShadow={options.hasShadow}
|
||||||
@@ -293,7 +296,7 @@ const SystemCharts = ({
|
|||||||
)}
|
)}
|
||||||
{showNetwork &&
|
{showNetwork &&
|
||||||
(width >= 300 ? (
|
(width >= 300 ? (
|
||||||
<Group h={chartHeight} gap="xs" grow wrap="nowrap">
|
<Group h="100%" gap="xs" grow wrap="nowrap">
|
||||||
<NetworkTrafficChart
|
<NetworkTrafficChart
|
||||||
usageOverTime={networkItems.map((network) => network.down)}
|
usageOverTime={networkItems.map((network) => network.down)}
|
||||||
isUp={false}
|
isUp={false}
|
||||||
@@ -310,7 +313,7 @@ const SystemCharts = ({
|
|||||||
/>
|
/>
|
||||||
</Group>
|
</Group>
|
||||||
) : (
|
) : (
|
||||||
<Box h={chartHeight}>
|
<Box h="100%">
|
||||||
<CombinedNetworkTrafficChart
|
<CombinedNetworkTrafficChart
|
||||||
usageOverTime={networkItems}
|
usageOverTime={networkItems}
|
||||||
hasShadow={options.hasShadow}
|
hasShadow={options.hasShadow}
|
||||||
|
|||||||
Generated
-9
@@ -1238,9 +1238,6 @@ importers:
|
|||||||
posthog-js:
|
posthog-js:
|
||||||
specifier: 'catalog:'
|
specifier: 'catalog:'
|
||||||
version: 1.399.2(preact-render-to-string@6.5.11)
|
version: 1.399.2(preact-render-to-string@6.5.11)
|
||||||
prismjs:
|
|
||||||
specifier: 'catalog:'
|
|
||||||
version: 1.30.0
|
|
||||||
react:
|
react:
|
||||||
specifier: 'catalog:'
|
specifier: 'catalog:'
|
||||||
version: 19.2.7
|
version: 19.2.7
|
||||||
@@ -1253,9 +1250,6 @@ importers:
|
|||||||
react-shiki:
|
react-shiki:
|
||||||
specifier: 'catalog:'
|
specifier: 'catalog:'
|
||||||
version: 0.11.0(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(supports-color@10.2.2)
|
version: 0.11.0(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(supports-color@10.2.2)
|
||||||
react-simple-code-editor:
|
|
||||||
specifier: 'catalog:'
|
|
||||||
version: 0.14.1(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
|
|
||||||
remark-breaks:
|
remark-breaks:
|
||||||
specifier: 'catalog:'
|
specifier: 'catalog:'
|
||||||
version: 4.0.0
|
version: 4.0.0
|
||||||
@@ -1299,9 +1293,6 @@ importers:
|
|||||||
'@types/node':
|
'@types/node':
|
||||||
specifier: 'catalog:'
|
specifier: 'catalog:'
|
||||||
version: 24.13.3
|
version: 24.13.3
|
||||||
'@types/prismjs':
|
|
||||||
specifier: 'catalog:'
|
|
||||||
version: 1.26.6
|
|
||||||
'@types/react':
|
'@types/react':
|
||||||
specifier: 'catalog:'
|
specifier: 'catalog:'
|
||||||
version: 19.2.17
|
version: 19.2.17
|
||||||
|
|||||||
@@ -0,0 +1,48 @@
|
|||||||
|
# Release-v2 mega QA seeder
|
||||||
|
|
||||||
|
This test-only harness creates deterministic users, boards, layouts, containers, integrations, Custom Widgets, and all current widget kinds for release-v2 browser QA. It does not change production migrations or the normal demo seed.
|
||||||
|
|
||||||
|
## Requirements
|
||||||
|
|
||||||
|
- Run the normal database migrations and base seed first.
|
||||||
|
- Use an isolated SQLite database through `DB_DRIVER=better-sqlite3` and an absolute `DB_URL`.
|
||||||
|
- Start the fixture service and provide its loopback URL as `QA_FIXTURE_URL`.
|
||||||
|
- Put the disposable QA password in a file and provide its path through `QA_PASSWORD_FILE`. The password is hashed and never written to the fixture manifest.
|
||||||
|
- Set the exact profile flags shown below. The seeder rejects missing, malformed, or mismatched values before changing the database.
|
||||||
|
|
||||||
|
## Commands
|
||||||
|
|
||||||
|
Start the deterministic fixture service:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pnpm qa:release-v2:fixtures -- --ready-file /tmp/release-v2-fixture-ready.json
|
||||||
|
```
|
||||||
|
|
||||||
|
Seed a populated writable fixture:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
DB_DRIVER=better-sqlite3 \
|
||||||
|
DB_URL=/absolute/path/to/qa.sqlite \
|
||||||
|
QA_FIXTURE_URL=http://127.0.0.1:<fixture-port> \
|
||||||
|
QA_PASSWORD_FILE=/absolute/path/to/qa-password \
|
||||||
|
DEMO_MODE=true \
|
||||||
|
DEMO_READ_ONLY=false \
|
||||||
|
UNSAFE_ENABLE_MOCK_INTEGRATION=true \
|
||||||
|
pnpm qa:release-v2:seed -- \
|
||||||
|
--profile main-writable \
|
||||||
|
--output /absolute/path/to/output \
|
||||||
|
--reset
|
||||||
|
```
|
||||||
|
|
||||||
|
Supported profiles:
|
||||||
|
|
||||||
|
| Profile | `DEMO_MODE` | `DEMO_READ_ONLY` | `UNSAFE_ENABLE_MOCK_INTEGRATION` |
|
||||||
|
| ------------------ | ----------- | ---------------- | -------------------------------- |
|
||||||
|
| `main-writable` | `true` | `false` | `true` |
|
||||||
|
| `main-readonly` | `true` | `true` | `true` |
|
||||||
|
| `onboarding-fresh` | `false` | `false` | `true` |
|
||||||
|
| `degraded` | `true` | `false` | `true` |
|
||||||
|
|
||||||
|
`onboarding-fresh` requires no QA password and removes only resources with fixed `qa-v2-*` identifiers. Populated profiles are idempotent. `--reset` remains narrowly scoped to those deterministic identifiers.
|
||||||
|
|
||||||
|
The generated `fixture-manifest.json` records the checkout SHA, profile, non-secret persona login usernames, boards, layouts, item counts, expected permissions, fixture URL, and runtime flags. It never contains a password, password hash, token, encryption key, or Custom Widget secret.
|
||||||
@@ -0,0 +1,349 @@
|
|||||||
|
import { createHash } from "node:crypto";
|
||||||
|
import { constants } from "node:fs";
|
||||||
|
import { createServer } from "node:http";
|
||||||
|
import type { IncomingMessage, ServerResponse } from "node:http";
|
||||||
|
import { readFile, mkdir, open, rename, rm } from "node:fs/promises";
|
||||||
|
import path from "node:path";
|
||||||
|
|
||||||
|
import { validateLoopbackHost } from "./safety.mts";
|
||||||
|
|
||||||
|
const DEFAULT_HOST = "127.0.0.1";
|
||||||
|
const MAX_BODY_BYTES = 2 * 1024 * 1024;
|
||||||
|
const MAX_SLOW_MS = 30_000;
|
||||||
|
|
||||||
|
const png = Buffer.from(
|
||||||
|
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=",
|
||||||
|
"base64",
|
||||||
|
);
|
||||||
|
|
||||||
|
const videoPath = path.resolve(
|
||||||
|
import.meta.dirname,
|
||||||
|
"../../apps/docs/src/components/pages/home/drag-and-drop/showcase-dark.mp4",
|
||||||
|
);
|
||||||
|
|
||||||
|
const createWav = () => {
|
||||||
|
const sampleRate = 8_000;
|
||||||
|
const sampleCount = sampleRate / 4;
|
||||||
|
const dataLength = sampleCount * 2;
|
||||||
|
const buffer = Buffer.alloc(44 + dataLength);
|
||||||
|
buffer.write("RIFF", 0);
|
||||||
|
buffer.writeUInt32LE(36 + dataLength, 4);
|
||||||
|
buffer.write("WAVEfmt ", 8);
|
||||||
|
buffer.writeUInt32LE(16, 16);
|
||||||
|
buffer.writeUInt16LE(1, 20);
|
||||||
|
buffer.writeUInt16LE(1, 22);
|
||||||
|
buffer.writeUInt32LE(sampleRate, 24);
|
||||||
|
buffer.writeUInt32LE(sampleRate * 2, 28);
|
||||||
|
buffer.writeUInt16LE(2, 32);
|
||||||
|
buffer.writeUInt16LE(16, 34);
|
||||||
|
buffer.write("data", 36);
|
||||||
|
buffer.writeUInt32LE(dataLength, 40);
|
||||||
|
for (let index = 0; index < sampleCount; index += 1) {
|
||||||
|
const value = Math.sin((2 * Math.PI * 440 * index) / sampleRate) * 0.2;
|
||||||
|
buffer.writeInt16LE(Math.round(value * 32_767), 44 + index * 2);
|
||||||
|
}
|
||||||
|
return buffer;
|
||||||
|
};
|
||||||
|
|
||||||
|
const wav = createWav();
|
||||||
|
|
||||||
|
const customWidgetPayload = {
|
||||||
|
title: "Release v2 QA fixture",
|
||||||
|
status: "ok",
|
||||||
|
items: [
|
||||||
|
{ id: "alpha", label: "Alpha", value: 42 },
|
||||||
|
{ id: "beta", label: "Beta", value: 7 },
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
const parseArgs = () => {
|
||||||
|
const args = process.argv.slice(2).filter((argument) => argument !== "--");
|
||||||
|
let host = DEFAULT_HOST;
|
||||||
|
let port = 0;
|
||||||
|
let readyFile: string | undefined;
|
||||||
|
|
||||||
|
for (let index = 0; index < args.length; index += 1) {
|
||||||
|
const argument = args[index];
|
||||||
|
const value = args[index + 1];
|
||||||
|
if (argument === "--host" && value) {
|
||||||
|
host = value;
|
||||||
|
index += 1;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (argument === "--port" && value) {
|
||||||
|
port = Number(value);
|
||||||
|
index += 1;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (argument === "--ready-file" && value) {
|
||||||
|
readyFile = path.resolve(value);
|
||||||
|
index += 1;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
throw new Error(`Unknown or incomplete argument: ${argument ?? "<missing>"}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!Number.isInteger(port) || port < 0 || port > 65_535) {
|
||||||
|
throw new Error("--port must be an integer between 0 and 65535");
|
||||||
|
}
|
||||||
|
return { host: validateLoopbackHost(host), port, readyFile };
|
||||||
|
};
|
||||||
|
|
||||||
|
const setCommonHeaders = (response: ServerResponse) => {
|
||||||
|
response.setHeader("Access-Control-Allow-Origin", "*");
|
||||||
|
response.setHeader("Access-Control-Allow-Headers", "Content-Type, X-QA-Fixture");
|
||||||
|
response.setHeader("Access-Control-Allow-Methods", "GET, HEAD, POST, PUT, OPTIONS");
|
||||||
|
response.setHeader("Cache-Control", "no-store");
|
||||||
|
response.setHeader("X-QA-Fixture", "release-v2");
|
||||||
|
};
|
||||||
|
|
||||||
|
const send = (response: ServerResponse, status: number, contentType: string, body: string | Buffer) => {
|
||||||
|
setCommonHeaders(response);
|
||||||
|
response.writeHead(status, { "Content-Type": contentType });
|
||||||
|
response.end(body);
|
||||||
|
};
|
||||||
|
|
||||||
|
const sendJson = (response: ServerResponse, status: number, value: unknown) => {
|
||||||
|
send(response, status, "application/json; charset=utf-8", `${JSON.stringify(value)}\n`);
|
||||||
|
};
|
||||||
|
|
||||||
|
const readBody = async (request: IncomingMessage) => {
|
||||||
|
const chunks: Buffer[] = [];
|
||||||
|
let size = 0;
|
||||||
|
for await (const chunk of request) {
|
||||||
|
const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
|
||||||
|
size += buffer.length;
|
||||||
|
if (size > MAX_BODY_BYTES) throw new Error("UPLOAD_TOO_LARGE");
|
||||||
|
chunks.push(buffer);
|
||||||
|
}
|
||||||
|
return Buffer.concat(chunks);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleRequest = async (request: IncomingMessage, response: ServerResponse) => {
|
||||||
|
const url = new URL(request.url ?? "/", `http://${request.headers.host ?? DEFAULT_HOST}`);
|
||||||
|
const method = request.method ?? "GET";
|
||||||
|
|
||||||
|
if (method === "OPTIONS") {
|
||||||
|
setCommonHeaders(response);
|
||||||
|
response.writeHead(204);
|
||||||
|
response.end();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (url.pathname === "/health") {
|
||||||
|
sendJson(response, 200, { status: "ok", fixture: "release-v2-qa" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (url.pathname === "/json") {
|
||||||
|
sendJson(response, 200, {
|
||||||
|
id: "qa-json-001",
|
||||||
|
name: "Release v2 deterministic JSON",
|
||||||
|
active: true,
|
||||||
|
count: 3,
|
||||||
|
tags: ["homarr", "release-v2", "qa"],
|
||||||
|
nested: { score: 99, nullable: null },
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (url.pathname === "/api/qa/custom-widget") {
|
||||||
|
sendJson(response, 200, customWidgetPayload);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (url.pathname === "/rss") {
|
||||||
|
send(
|
||||||
|
response,
|
||||||
|
200,
|
||||||
|
"application/rss+xml; charset=utf-8",
|
||||||
|
`<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<rss version="2.0"><channel><title>Release v2 QA</title><link>https://example.invalid/qa</link><description>Deterministic feed</description><item><guid>qa-entry-1</guid><title>Alpha release note</title><link>https://example.invalid/qa/alpha</link><pubDate>Thu, 01 Jan 2026 00:00:00 GMT</pubDate><description>Stable fixture entry</description></item><item><guid>qa-entry-2</guid><title>Beta release note</title><link>https://example.invalid/qa/beta</link><pubDate>Fri, 02 Jan 2026 00:00:00 GMT</pubDate><description>Second stable fixture entry</description></item></channel></rss>\n`,
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (url.pathname === "/media/image.png") {
|
||||||
|
send(response, 200, "image/png", png);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (url.pathname === "/media/image.svg") {
|
||||||
|
send(
|
||||||
|
response,
|
||||||
|
200,
|
||||||
|
"image/svg+xml; charset=utf-8",
|
||||||
|
'<svg xmlns="http://www.w3.org/2000/svg" width="320" height="180" viewBox="0 0 320 180"><rect width="320" height="180" fill="#111827"/><circle cx="72" cy="90" r="40" fill="#38bdf8"/><text x="132" y="98" fill="#f8fafc" font-family="sans-serif" font-size="22">Release v2 QA</text></svg>\n',
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (url.pathname === "/media/audio.wav") {
|
||||||
|
send(response, 200, "audio/wav", wav);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (url.pathname === "/media/video.mp4") {
|
||||||
|
const video = await readFile(videoPath);
|
||||||
|
const range = request.headers.range;
|
||||||
|
setCommonHeaders(response);
|
||||||
|
response.setHeader("Accept-Ranges", "bytes");
|
||||||
|
if (!range) {
|
||||||
|
response.writeHead(200, { "Content-Type": "video/mp4", "Content-Length": video.length });
|
||||||
|
response.end(method === "HEAD" ? undefined : video);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const match = /^bytes=(\d+)-(\d*)$/.exec(range);
|
||||||
|
if (!match) {
|
||||||
|
response.writeHead(416, { "Content-Range": `bytes */${video.length}` });
|
||||||
|
response.end();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const start = Number(match[1]);
|
||||||
|
const requestedEnd = match[2] ? Number(match[2]) : video.length - 1;
|
||||||
|
const end = Math.min(requestedEnd, video.length - 1);
|
||||||
|
if (start > end || start >= video.length) {
|
||||||
|
response.writeHead(416, { "Content-Range": `bytes */${video.length}` });
|
||||||
|
response.end();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const body = video.subarray(start, end + 1);
|
||||||
|
response.writeHead(206, {
|
||||||
|
"Content-Type": "video/mp4",
|
||||||
|
"Content-Length": body.length,
|
||||||
|
"Content-Range": `bytes ${start}-${end}/${video.length}`,
|
||||||
|
});
|
||||||
|
response.end(method === "HEAD" ? undefined : body);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (url.pathname === "/iframe") {
|
||||||
|
send(
|
||||||
|
response,
|
||||||
|
200,
|
||||||
|
"text/html; charset=utf-8",
|
||||||
|
'<!doctype html><html><head><meta charset="utf-8"><title>Release v2 QA iframe</title></head><body><main data-qa-fixture="iframe"><h1>Deterministic iframe</h1><p>release-v2-qa</p></main></body></html>\n',
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (url.pathname === "/api/qa/download/sample.txt") {
|
||||||
|
setCommonHeaders(response);
|
||||||
|
response.writeHead(200, {
|
||||||
|
"Content-Type": "text/plain; charset=utf-8",
|
||||||
|
"Content-Disposition": 'attachment; filename="release-v2-qa-sample.txt"',
|
||||||
|
});
|
||||||
|
response.end("Release v2 QA deterministic download\nline=2\n");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (url.pathname === "/upload") {
|
||||||
|
if (method !== "POST" && method !== "PUT") {
|
||||||
|
sendJson(response, 405, { error: "METHOD_NOT_ALLOWED", allowed: ["POST", "PUT"] });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const body = await readBody(request);
|
||||||
|
sendJson(response, 200, {
|
||||||
|
uploaded: true,
|
||||||
|
method,
|
||||||
|
bytes: body.length,
|
||||||
|
contentType: request.headers["content-type"] ?? null,
|
||||||
|
sha256: createHash("sha256").update(body).digest("hex"),
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof Error && error.message === "UPLOAD_TOO_LARGE") {
|
||||||
|
sendJson(response, 413, { error: "UPLOAD_TOO_LARGE", maxBytes: MAX_BODY_BYTES });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (url.pathname === "/empty") {
|
||||||
|
setCommonHeaders(response);
|
||||||
|
response.writeHead(204);
|
||||||
|
response.end();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (url.pathname === "/malformed" || url.pathname === "/malformed/json") {
|
||||||
|
send(response, 200, "application/json; charset=utf-8", '{"status":"broken","items":[1,2,}\n');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (url.pathname === "/slow") {
|
||||||
|
const requestedMs = Number(url.searchParams.get("ms") ?? "1500");
|
||||||
|
const delayMs = Number.isFinite(requestedMs) ? Math.max(0, Math.min(Math.trunc(requestedMs), MAX_SLOW_MS)) : 1500;
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, delayMs));
|
||||||
|
sendJson(response, 200, { status: "ok", delayedMs: delayMs });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (url.pathname === "/error" || url.pathname.startsWith("/error/")) {
|
||||||
|
const pathStatus = url.pathname.split("/")[2];
|
||||||
|
const requestedStatus = Number(url.searchParams.get("status") ?? pathStatus ?? "503");
|
||||||
|
const status =
|
||||||
|
Number.isInteger(requestedStatus) && requestedStatus >= 400 && requestedStatus <= 599 ? requestedStatus : 503;
|
||||||
|
sendJson(response, status, { error: "QA_CONFIGURED_HTTP_ERROR", status });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
sendJson(response, 404, { error: "NOT_FOUND", path: url.pathname });
|
||||||
|
};
|
||||||
|
|
||||||
|
const writeJsonAtomic = async (filePath: string, value: unknown) => {
|
||||||
|
await mkdir(path.dirname(filePath), { recursive: true });
|
||||||
|
const temporaryPath = `${filePath}.${process.pid}.tmp`;
|
||||||
|
const handle = await open(
|
||||||
|
temporaryPath,
|
||||||
|
constants.O_CREAT | constants.O_EXCL | constants.O_WRONLY | constants.O_NOFOLLOW,
|
||||||
|
0o600,
|
||||||
|
);
|
||||||
|
try {
|
||||||
|
await handle.writeFile(`${JSON.stringify(value, null, 2)}\n`, "utf8");
|
||||||
|
await handle.sync();
|
||||||
|
} finally {
|
||||||
|
await handle.close();
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
await rename(temporaryPath, filePath);
|
||||||
|
} catch (error) {
|
||||||
|
await rm(temporaryPath, { force: true });
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const main = async () => {
|
||||||
|
const options = parseArgs();
|
||||||
|
const server = createServer((request, response) => {
|
||||||
|
void handleRequest(request, response).catch((error: unknown) => {
|
||||||
|
console.error(error);
|
||||||
|
if (!response.headersSent) sendJson(response, 500, { error: "FIXTURE_INTERNAL_ERROR" });
|
||||||
|
else response.destroy();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
await new Promise<void>((resolve, reject) => {
|
||||||
|
server.once("error", reject);
|
||||||
|
server.listen(options.port, options.host, resolve);
|
||||||
|
});
|
||||||
|
const address = server.address();
|
||||||
|
if (!address || typeof address === "string") throw new Error("Fixture server did not expose a TCP address");
|
||||||
|
const urlHost = options.host.includes(":") ? `[${options.host}]` : options.host;
|
||||||
|
const url = `http://${urlHost}:${address.port}`;
|
||||||
|
const ready = { schemaVersion: 1, pid: process.pid, host: options.host, port: address.port, url };
|
||||||
|
if (options.readyFile) await writeJsonAtomic(options.readyFile, ready);
|
||||||
|
process.stdout.write(`${JSON.stringify(ready)}\n`);
|
||||||
|
|
||||||
|
const shutdown = () => {
|
||||||
|
server.close(() => process.exit(0));
|
||||||
|
setTimeout(() => process.exit(1), 5_000).unref();
|
||||||
|
};
|
||||||
|
process.once("SIGINT", shutdown);
|
||||||
|
process.once("SIGTERM", shutdown);
|
||||||
|
};
|
||||||
|
|
||||||
|
await main();
|
||||||
@@ -0,0 +1,152 @@
|
|||||||
|
import type { LayoutRole } from "../../packages/definitions/index.ts";
|
||||||
|
import { getBoardLaneColumnCount, getRootSectionLane } from "../../packages/definitions/index.ts";
|
||||||
|
|
||||||
|
interface GeometryLayout {
|
||||||
|
id: string;
|
||||||
|
boardId: string;
|
||||||
|
columnCount: number;
|
||||||
|
leftGutterColumnCount: number;
|
||||||
|
rightGutterColumnCount: number;
|
||||||
|
role: LayoutRole;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface GeometrySection {
|
||||||
|
id: string;
|
||||||
|
boardId: string;
|
||||||
|
kind: "empty" | "container";
|
||||||
|
xOffset: number | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface GeometryPlacement {
|
||||||
|
layoutId: string;
|
||||||
|
sectionId: string;
|
||||||
|
xOffset: number;
|
||||||
|
yOffset: number;
|
||||||
|
width: number;
|
||||||
|
height: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface GeometrySectionLayout extends GeometryPlacement {
|
||||||
|
parentSectionId: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface GeometryItemLayout extends GeometryPlacement {
|
||||||
|
itemId: string;
|
||||||
|
boardId: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface FixtureGeometry {
|
||||||
|
layouts: GeometryLayout[];
|
||||||
|
sections: GeometrySection[];
|
||||||
|
sectionLayouts: GeometrySectionLayout[];
|
||||||
|
itemLayouts: GeometryItemLayout[];
|
||||||
|
}
|
||||||
|
|
||||||
|
const placementIsValid = (placement: GeometryPlacement) =>
|
||||||
|
Number.isInteger(placement.xOffset) &&
|
||||||
|
Number.isInteger(placement.yOffset) &&
|
||||||
|
Number.isInteger(placement.width) &&
|
||||||
|
Number.isInteger(placement.height) &&
|
||||||
|
placement.xOffset >= 0 &&
|
||||||
|
placement.yOffset >= 0 &&
|
||||||
|
placement.width >= 1 &&
|
||||||
|
placement.height >= 1;
|
||||||
|
|
||||||
|
export const findFixtureGeometryErrors = (geometry: FixtureGeometry) => {
|
||||||
|
const errors: string[] = [];
|
||||||
|
const layoutsById = new Map(geometry.layouts.map((layout) => [layout.id, layout]));
|
||||||
|
const sectionsById = new Map(geometry.sections.map((section) => [section.id, section]));
|
||||||
|
const sectionLayoutsByKey = new Map<string, GeometrySectionLayout>();
|
||||||
|
|
||||||
|
for (const sectionLayout of geometry.sectionLayouts) {
|
||||||
|
const key = `${sectionLayout.layoutId}\0${sectionLayout.sectionId}`;
|
||||||
|
if (sectionLayoutsByKey.has(key)) {
|
||||||
|
errors.push(`container ${sectionLayout.sectionId} has duplicate placement in layout ${sectionLayout.layoutId}`);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
sectionLayoutsByKey.set(key, sectionLayout);
|
||||||
|
}
|
||||||
|
|
||||||
|
const parentCapacity = (
|
||||||
|
layout: GeometryLayout,
|
||||||
|
parentSection: GeometrySection,
|
||||||
|
): { columns: number; kind: "lane" | "container" } | undefined => {
|
||||||
|
if (parentSection.kind === "empty") {
|
||||||
|
return {
|
||||||
|
columns: getBoardLaneColumnCount(layout, getRootSectionLane(parentSection.xOffset)),
|
||||||
|
kind: "lane",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const parentLayout = sectionLayoutsByKey.get(`${layout.id}\0${parentSection.id}`);
|
||||||
|
if (!parentLayout) return undefined;
|
||||||
|
return { columns: parentLayout.width, kind: "container" };
|
||||||
|
};
|
||||||
|
|
||||||
|
for (const sectionLayout of geometry.sectionLayouts) {
|
||||||
|
const layout = layoutsById.get(sectionLayout.layoutId);
|
||||||
|
const section = sectionsById.get(sectionLayout.sectionId);
|
||||||
|
const parent = sectionLayout.parentSectionId ? sectionsById.get(sectionLayout.parentSectionId) : undefined;
|
||||||
|
if (!layout || !section || !parent) {
|
||||||
|
errors.push(
|
||||||
|
`container ${sectionLayout.sectionId} in layout ${sectionLayout.layoutId} has unknown geometry references`,
|
||||||
|
);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (section.kind !== "container" || section.boardId !== layout.boardId || parent.boardId !== layout.boardId) {
|
||||||
|
errors.push(`container ${section.id} in layout ${layout.id} crosses a board or root boundary`);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (!placementIsValid(sectionLayout)) {
|
||||||
|
errors.push(`container ${section.id} in layout ${layout.id} has an invalid placement`);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const capacity = parentCapacity(layout, parent);
|
||||||
|
if (!capacity) {
|
||||||
|
errors.push(`container ${section.id} in layout ${layout.id} has an unplaced parent container`);
|
||||||
|
} else if (sectionLayout.xOffset + sectionLayout.width > capacity.columns) {
|
||||||
|
errors.push(
|
||||||
|
`container ${section.id} in layout ${layout.id} exceeds its ${capacity.columns}-column parent ${capacity.kind}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const visited = new Set<string>();
|
||||||
|
let currentSectionId: string | null = section.id;
|
||||||
|
while (currentSectionId) {
|
||||||
|
if (visited.has(currentSectionId)) {
|
||||||
|
errors.push(`container cycle detected in layout ${layout.id} at ${currentSectionId}`);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
visited.add(currentSectionId);
|
||||||
|
const current = sectionsById.get(currentSectionId);
|
||||||
|
if (!current || current.kind === "empty") break;
|
||||||
|
currentSectionId = sectionLayoutsByKey.get(`${layout.id}\0${currentSectionId}`)?.parentSectionId ?? null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const itemLayout of geometry.itemLayouts) {
|
||||||
|
const layout = layoutsById.get(itemLayout.layoutId);
|
||||||
|
const parent = sectionsById.get(itemLayout.sectionId);
|
||||||
|
if (!layout || !parent || itemLayout.boardId !== layout.boardId || parent.boardId !== layout.boardId) {
|
||||||
|
errors.push(`item ${itemLayout.itemId} in layout ${itemLayout.layoutId} crosses a board or section boundary`);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (!placementIsValid(itemLayout)) {
|
||||||
|
errors.push(`item ${itemLayout.itemId} in layout ${layout.id} has an invalid placement`);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const capacity = parentCapacity(layout, parent);
|
||||||
|
if (!capacity) {
|
||||||
|
errors.push(`item ${itemLayout.itemId} in layout ${layout.id} has an unplaced parent container`);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (itemLayout.xOffset + itemLayout.width > capacity.columns) {
|
||||||
|
errors.push(
|
||||||
|
`item ${itemLayout.itemId} in layout ${layout.id} exceeds its ${capacity.columns}-column parent ${capacity.kind}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return errors;
|
||||||
|
};
|
||||||
@@ -0,0 +1,119 @@
|
|||||||
|
// @vitest-environment node
|
||||||
|
|
||||||
|
import { describe, expect, test } from "vitest";
|
||||||
|
|
||||||
|
import type { FixtureGeometry } from "./geometry.mts";
|
||||||
|
import { findFixtureGeometryErrors } from "./geometry.mts";
|
||||||
|
|
||||||
|
const baseGeometry = (): FixtureGeometry => ({
|
||||||
|
layouts: [
|
||||||
|
{
|
||||||
|
id: "layout-mobile",
|
||||||
|
boardId: "board",
|
||||||
|
columnCount: 3,
|
||||||
|
leftGutterColumnCount: 0,
|
||||||
|
rightGutterColumnCount: 0,
|
||||||
|
role: "mobile",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
sections: [
|
||||||
|
{ id: "root", boardId: "board", kind: "empty", xOffset: 0 },
|
||||||
|
{ id: "level-1", boardId: "board", kind: "container", xOffset: null },
|
||||||
|
{ id: "level-2", boardId: "board", kind: "container", xOffset: null },
|
||||||
|
{ id: "level-3", boardId: "board", kind: "container", xOffset: null },
|
||||||
|
],
|
||||||
|
sectionLayouts: [
|
||||||
|
{
|
||||||
|
sectionId: "level-1",
|
||||||
|
layoutId: "layout-mobile",
|
||||||
|
parentSectionId: "root",
|
||||||
|
xOffset: 0,
|
||||||
|
yOffset: 0,
|
||||||
|
width: 3,
|
||||||
|
height: 6,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
sectionId: "level-2",
|
||||||
|
layoutId: "layout-mobile",
|
||||||
|
parentSectionId: "level-1",
|
||||||
|
xOffset: 0,
|
||||||
|
yOffset: 0,
|
||||||
|
width: 2,
|
||||||
|
height: 5,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
sectionId: "level-3",
|
||||||
|
layoutId: "layout-mobile",
|
||||||
|
parentSectionId: "level-2",
|
||||||
|
xOffset: 0,
|
||||||
|
yOffset: 0,
|
||||||
|
width: 1,
|
||||||
|
height: 4,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
itemLayouts: [
|
||||||
|
{
|
||||||
|
itemId: "nested-item",
|
||||||
|
boardId: "board",
|
||||||
|
sectionId: "level-3",
|
||||||
|
layoutId: "layout-mobile",
|
||||||
|
xOffset: 0,
|
||||||
|
yOffset: 0,
|
||||||
|
width: 1,
|
||||||
|
height: 2,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("release-v2 QA fixture geometry", () => {
|
||||||
|
test("rejects an item that fits total columns but overflows the gutter-reduced main lane", () => {
|
||||||
|
const geometry = baseGeometry();
|
||||||
|
geometry.layouts[0] = {
|
||||||
|
id: "layout-mobile",
|
||||||
|
boardId: "board",
|
||||||
|
role: "base",
|
||||||
|
columnCount: 12,
|
||||||
|
leftGutterColumnCount: 2,
|
||||||
|
rightGutterColumnCount: 2,
|
||||||
|
};
|
||||||
|
geometry.sections = [{ id: "root", boardId: "board", kind: "empty", xOffset: 0 }];
|
||||||
|
geometry.sectionLayouts = [];
|
||||||
|
geometry.itemLayouts = [
|
||||||
|
{
|
||||||
|
itemId: "gutter-overflow",
|
||||||
|
boardId: "board",
|
||||||
|
sectionId: "root",
|
||||||
|
layoutId: "layout-mobile",
|
||||||
|
xOffset: 0,
|
||||||
|
yOffset: 0,
|
||||||
|
width: 9,
|
||||||
|
height: 1,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
expect(findFixtureGeometryErrors(geometry)).toContain(
|
||||||
|
"item gutter-overflow in layout layout-mobile exceeds its 8-column parent lane",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("accepts valid three-level mobile nesting and rejects a child wider than its container", () => {
|
||||||
|
const geometry = baseGeometry();
|
||||||
|
expect(findFixtureGeometryErrors(geometry)).toEqual([]);
|
||||||
|
|
||||||
|
const nestedItem = geometry.itemLayouts.at(0);
|
||||||
|
if (!nestedItem) throw new Error("Missing nested fixture item");
|
||||||
|
nestedItem.width = 2;
|
||||||
|
expect(findFixtureGeometryErrors(geometry)).toContain(
|
||||||
|
"item nested-item in layout layout-mobile exceeds its 1-column parent container",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("rejects recursive container parent cycles", () => {
|
||||||
|
const geometry = baseGeometry();
|
||||||
|
const firstSection = geometry.sectionLayouts.at(0);
|
||||||
|
if (!firstSection) throw new Error("Missing nested fixture section");
|
||||||
|
firstSection.parentSectionId = "level-3";
|
||||||
|
|
||||||
|
expect(findFixtureGeometryErrors(geometry).some((error) => error.includes("container cycle"))).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,76 @@
|
|||||||
|
import { spawnSync } from "node:child_process";
|
||||||
|
|
||||||
|
const fullShaPattern = /^[0-9a-f]{40}$/iu;
|
||||||
|
|
||||||
|
export const releaseV2QaProfiles = ["main-writable", "main-readonly", "onboarding-fresh", "degraded"] as const;
|
||||||
|
export type ReleaseV2QaProfile = (typeof releaseV2QaProfiles)[number];
|
||||||
|
|
||||||
|
export interface ReleaseV2QaProfileFlags {
|
||||||
|
demoMode: boolean;
|
||||||
|
demoReadOnly: boolean;
|
||||||
|
unsafeMockIntegration: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const expectedProfileFlags: Record<ReleaseV2QaProfile, ReleaseV2QaProfileFlags> = {
|
||||||
|
"main-writable": { demoMode: true, demoReadOnly: false, unsafeMockIntegration: true },
|
||||||
|
"main-readonly": { demoMode: true, demoReadOnly: true, unsafeMockIntegration: true },
|
||||||
|
"onboarding-fresh": { demoMode: false, demoReadOnly: false, unsafeMockIntegration: true },
|
||||||
|
degraded: { demoMode: true, demoReadOnly: false, unsafeMockIntegration: true },
|
||||||
|
};
|
||||||
|
|
||||||
|
const profileFlagEnvironmentNames = {
|
||||||
|
demoMode: "DEMO_MODE",
|
||||||
|
demoReadOnly: "DEMO_READ_ONLY",
|
||||||
|
unsafeMockIntegration: "UNSAFE_ENABLE_MOCK_INTEGRATION",
|
||||||
|
} as const satisfies Record<keyof ReleaseV2QaProfileFlags, string>;
|
||||||
|
|
||||||
|
const parseStrictBoolean = (value: string | undefined) => {
|
||||||
|
if (value === "true") return true;
|
||||||
|
if (value === "false") return false;
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const assertReleaseV2QaProfileEnvironment = (
|
||||||
|
profile: ReleaseV2QaProfile,
|
||||||
|
environment: NodeJS.ProcessEnv,
|
||||||
|
): ReleaseV2QaProfileFlags => {
|
||||||
|
const expected = expectedProfileFlags[profile];
|
||||||
|
const invalidFlags: string[] = [];
|
||||||
|
|
||||||
|
for (const [flag, environmentName] of Object.entries(profileFlagEnvironmentNames) as Array<
|
||||||
|
[keyof ReleaseV2QaProfileFlags, string]
|
||||||
|
>) {
|
||||||
|
const expectedValue = expected[flag];
|
||||||
|
const actualValue = parseStrictBoolean(environment[environmentName]);
|
||||||
|
if (actualValue !== expectedValue) invalidFlags.push(`${environmentName} (expected ${String(expectedValue)})`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (invalidFlags.length > 0) {
|
||||||
|
throw new Error(`QA profile '${profile}' has invalid or missing environment flags: ${invalidFlags.join(", ")}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return { ...expected };
|
||||||
|
};
|
||||||
|
|
||||||
|
export const validateCandidateSha = (candidateSha: string, checkoutSha: string) => {
|
||||||
|
if (!fullShaPattern.test(candidateSha)) {
|
||||||
|
throw new Error("QA candidate must be a full 40-character Git SHA");
|
||||||
|
}
|
||||||
|
if (!fullShaPattern.test(checkoutSha)) {
|
||||||
|
throw new Error("The checked-out HEAD did not resolve to a full 40-character Git SHA");
|
||||||
|
}
|
||||||
|
if (candidateSha.toLowerCase() !== checkoutSha.toLowerCase()) {
|
||||||
|
throw new Error("QA candidate does not match the checked-out HEAD");
|
||||||
|
}
|
||||||
|
return checkoutSha.toLowerCase();
|
||||||
|
};
|
||||||
|
|
||||||
|
export const resolveCheckoutCandidateSha = (repoRoot: string) => {
|
||||||
|
const result = spawnSync("git", ["rev-parse", "--verify", "HEAD^{commit}"], {
|
||||||
|
cwd: repoRoot,
|
||||||
|
encoding: "utf8",
|
||||||
|
});
|
||||||
|
if (result.status !== 0) throw new Error("Could not resolve the checked-out HEAD for QA provenance");
|
||||||
|
const checkoutSha = result.stdout.trim();
|
||||||
|
return validateCandidateSha(checkoutSha, checkoutSha);
|
||||||
|
};
|
||||||
@@ -0,0 +1,133 @@
|
|||||||
|
// @vitest-environment node
|
||||||
|
|
||||||
|
import { spawnSync } from "node:child_process";
|
||||||
|
import { access, mkdtemp, readFile, rm } from "node:fs/promises";
|
||||||
|
import { tmpdir } from "node:os";
|
||||||
|
import path from "node:path";
|
||||||
|
|
||||||
|
import Database from "better-sqlite3";
|
||||||
|
import { afterEach, describe, expect, test } from "vitest";
|
||||||
|
|
||||||
|
import { assertReleaseV2QaProfileEnvironment, releaseV2QaProfiles, validateCandidateSha } from "./provenance.mts";
|
||||||
|
import type { ReleaseV2QaProfile } from "./provenance.mts";
|
||||||
|
|
||||||
|
const disposableDirectories: string[] = [];
|
||||||
|
const repoRoot = path.resolve(import.meta.dirname, "../..");
|
||||||
|
const profileEnvironmentNames = ["DEMO_MODE", "DEMO_READ_ONLY", "UNSAFE_ENABLE_MOCK_INTEGRATION"] as const;
|
||||||
|
|
||||||
|
afterEach(async () => {
|
||||||
|
await Promise.all(
|
||||||
|
disposableDirectories.splice(0).map((directory) => rm(directory, { force: true, recursive: true })),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
const environmentForProfile = (profile: ReleaseV2QaProfile): NodeJS.ProcessEnv => {
|
||||||
|
const environment: NodeJS.ProcessEnv = {
|
||||||
|
DEMO_MODE: "true",
|
||||||
|
DEMO_READ_ONLY: "false",
|
||||||
|
UNSAFE_ENABLE_MOCK_INTEGRATION: "true",
|
||||||
|
};
|
||||||
|
if (profile === "main-readonly") environment.DEMO_READ_ONLY = "true";
|
||||||
|
if (profile === "onboarding-fresh") environment.DEMO_MODE = "false";
|
||||||
|
return environment;
|
||||||
|
};
|
||||||
|
|
||||||
|
describe("release-v2 QA candidate provenance", () => {
|
||||||
|
test("accepts only the exact checked-out commit", () => {
|
||||||
|
const checkoutSha = "a".repeat(40);
|
||||||
|
|
||||||
|
expect(validateCandidateSha(checkoutSha.toUpperCase(), checkoutSha)).toBe(checkoutSha);
|
||||||
|
expect(() => validateCandidateSha("b".repeat(40), checkoutSha)).toThrow("checked-out HEAD");
|
||||||
|
expect(() => validateCandidateSha("not-a-sha", checkoutSha)).toThrow("full 40-character Git SHA");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("release-v2 QA profile provenance", () => {
|
||||||
|
test.each(releaseV2QaProfiles)("accepts the exact %s profile matrix", (profile) => {
|
||||||
|
expect(assertReleaseV2QaProfileEnvironment(profile, environmentForProfile(profile))).toEqual({
|
||||||
|
demoMode: profile !== "onboarding-fresh",
|
||||||
|
demoReadOnly: profile === "main-readonly",
|
||||||
|
unsafeMockIntegration: true,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test.each(profileEnvironmentNames)("rejects an unset %s flag", (environmentName) => {
|
||||||
|
const environment = environmentForProfile("main-writable");
|
||||||
|
delete environment[environmentName];
|
||||||
|
|
||||||
|
expect(() => assertReleaseV2QaProfileEnvironment("main-writable", environment)).toThrow(
|
||||||
|
new RegExp(`${environmentName} \\(expected (?:true|false)\\)`, "u"),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test.each(["1", "0", "TRUE", "False", "yes", " true ", "", "unexpected-private-marker"])(
|
||||||
|
"rejects the malformed strict boolean %j without echoing it",
|
||||||
|
(malformedValue) => {
|
||||||
|
for (const environmentName of profileEnvironmentNames) {
|
||||||
|
const environment = environmentForProfile("main-writable");
|
||||||
|
environment[environmentName] = malformedValue;
|
||||||
|
|
||||||
|
expect(() => assertReleaseV2QaProfileEnvironment("main-writable", environment)).toThrow(environmentName);
|
||||||
|
try {
|
||||||
|
assertReleaseV2QaProfileEnvironment("main-writable", environment);
|
||||||
|
} catch (error) {
|
||||||
|
expect((error as Error).message).not.toContain(malformedValue || "unexpected-private-marker");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
test.each(releaseV2QaProfiles)("rejects every mismatched flag for %s", (profile) => {
|
||||||
|
const expectedEnvironment = environmentForProfile(profile);
|
||||||
|
for (const environmentName of profileEnvironmentNames) {
|
||||||
|
const environment = { ...expectedEnvironment };
|
||||||
|
environment[environmentName] = environment[environmentName] === "true" ? "false" : "true";
|
||||||
|
expect(() => assertReleaseV2QaProfileEnvironment(profile, environment)).toThrow(environmentName);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("rejects a writable ambient environment before a read-only reset or manifest write", async () => {
|
||||||
|
const disposableDirectory = await mkdtemp(path.join(tmpdir(), "homarr-release-v2-qa-profile-flags-"));
|
||||||
|
disposableDirectories.push(disposableDirectory);
|
||||||
|
const databasePath = path.join(disposableDirectory, "fixture.sqlite");
|
||||||
|
const outputDirectory = path.join(disposableDirectory, "output");
|
||||||
|
const database = new Database(databasePath);
|
||||||
|
database.exec("CREATE TABLE mutation_guard (value TEXT NOT NULL); INSERT INTO mutation_guard VALUES ('preserved')");
|
||||||
|
database.close();
|
||||||
|
const databaseBefore = await readFile(databasePath);
|
||||||
|
|
||||||
|
const result = spawnSync(
|
||||||
|
process.execPath,
|
||||||
|
[
|
||||||
|
"--import",
|
||||||
|
"tsx",
|
||||||
|
path.join(repoRoot, "scripts/release-v2-qa/seed.mts"),
|
||||||
|
"--profile",
|
||||||
|
"main-readonly",
|
||||||
|
"--output",
|
||||||
|
outputDirectory,
|
||||||
|
"--reset",
|
||||||
|
],
|
||||||
|
{
|
||||||
|
cwd: repoRoot,
|
||||||
|
encoding: "utf8",
|
||||||
|
env: {
|
||||||
|
...process.env,
|
||||||
|
DB_DRIVER: "better-sqlite3",
|
||||||
|
DB_URL: databasePath,
|
||||||
|
DEMO_MODE: "true",
|
||||||
|
DEMO_READ_ONLY: "false",
|
||||||
|
UNSAFE_ENABLE_MOCK_INTEGRATION: "true",
|
||||||
|
},
|
||||||
|
timeout: 30_000,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(result.error).toBeUndefined();
|
||||||
|
expect(result.status).toBe(1);
|
||||||
|
expect(result.stderr).toContain("DEMO_READ_ONLY (expected true)");
|
||||||
|
expect(result.stderr).not.toContain("no such table");
|
||||||
|
expect(await readFile(databasePath)).toEqual(databaseBefore);
|
||||||
|
await expect(access(path.join(outputDirectory, "fixture-manifest.json"))).rejects.toMatchObject({ code: "ENOENT" });
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,378 @@
|
|||||||
|
import { randomUUID } from "node:crypto";
|
||||||
|
import { lstat, open, readFile, realpath, unlink } from "node:fs/promises";
|
||||||
|
import { tmpdir } from "node:os";
|
||||||
|
import path from "node:path";
|
||||||
|
|
||||||
|
const runRootNamePattern = /^homarr-release-v2-qa(?:$|-[A-Za-z0-9][A-Za-z0-9._-]*)$/u;
|
||||||
|
const zoomOutputNamePattern = /^homarr-release-v2-qa-zoom-[A-Za-z0-9][A-Za-z0-9._-]*$/u;
|
||||||
|
const ipv6LoopbackHosts = new Set(["::1", "0:0:0:0:0:0:0:1"]);
|
||||||
|
const leaseSchemaVersion = 1;
|
||||||
|
const incompleteLeaseGraceMs = 30_000;
|
||||||
|
|
||||||
|
const missingPath = (error: unknown) => (error as NodeJS.ErrnoException).code === "ENOENT";
|
||||||
|
const existingPath = (error: unknown) => (error as NodeJS.ErrnoException).code === "EEXIST";
|
||||||
|
|
||||||
|
export const assertSupportedRuntimePlatform = (platform: NodeJS.Platform = process.platform) => {
|
||||||
|
if (platform !== "linux") {
|
||||||
|
throw new Error(
|
||||||
|
"Release-v2 QA runtime mutations are supported only on Linux because safe process ownership and cleanup require procfs and POSIX process groups",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const isPathWithin = (parent: string, child: string) => {
|
||||||
|
if (!path.isAbsolute(parent) || !path.isAbsolute(child)) return false;
|
||||||
|
const relativePath = path.relative(path.resolve(parent), path.resolve(child));
|
||||||
|
return relativePath.length === 0 || (!relativePath.startsWith("..") && !path.isAbsolute(relativePath));
|
||||||
|
};
|
||||||
|
|
||||||
|
const canonicalTemporaryParent = async () => await realpath(path.resolve(tmpdir()));
|
||||||
|
|
||||||
|
const assertSafeTemporaryChild = async (
|
||||||
|
target: string,
|
||||||
|
options: {
|
||||||
|
allowMissing?: boolean;
|
||||||
|
label: string;
|
||||||
|
namePattern: RegExp;
|
||||||
|
nameRequirement: string;
|
||||||
|
},
|
||||||
|
) => {
|
||||||
|
const resolvedTarget = path.resolve(target);
|
||||||
|
const canonicalParent = await canonicalTemporaryParent();
|
||||||
|
const targetParent = path.dirname(resolvedTarget);
|
||||||
|
let realTargetParent: string;
|
||||||
|
try {
|
||||||
|
realTargetParent = await realpath(targetParent);
|
||||||
|
} catch (error) {
|
||||||
|
if (missingPath(error)) {
|
||||||
|
throw new Error(`${options.label} must be a direct child of the operating system temporary directory`, {
|
||||||
|
cause: error,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
if (realTargetParent !== canonicalParent) {
|
||||||
|
throw new Error(`${options.label} must be a direct child of the operating system temporary directory`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const targetName = path.basename(resolvedTarget);
|
||||||
|
if (!options.namePattern.test(targetName)) {
|
||||||
|
throw new Error(options.nameRequirement);
|
||||||
|
}
|
||||||
|
|
||||||
|
const canonicalTarget = path.join(canonicalParent, targetName);
|
||||||
|
try {
|
||||||
|
const stats = await lstat(canonicalTarget);
|
||||||
|
if (stats.isSymbolicLink()) throw new Error(`${options.label} must not be a symlink`);
|
||||||
|
if (!stats.isDirectory()) throw new Error(`${options.label} must be a directory`);
|
||||||
|
if ((await realpath(canonicalTarget)) !== canonicalTarget) {
|
||||||
|
throw new Error(`${options.label} must not contain symlink components`);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
if (missingPath(error) && options.allowMissing) return canonicalTarget;
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
|
||||||
|
return canonicalTarget;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const assertSafeContainedPath = async (parent: string, child: string, label: string) => {
|
||||||
|
const resolvedParent = path.resolve(parent);
|
||||||
|
const resolvedChild = path.resolve(child);
|
||||||
|
if (!isPathWithin(resolvedParent, resolvedChild)) {
|
||||||
|
throw new Error(`${label} escapes its approved parent`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const realParent = await realpath(resolvedParent);
|
||||||
|
if (realParent !== resolvedParent) {
|
||||||
|
throw new Error(`${label} parent contains a symlink`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const relativePath = path.relative(resolvedParent, resolvedChild);
|
||||||
|
let currentPath = resolvedParent;
|
||||||
|
for (const segment of relativePath.split(path.sep).filter(Boolean)) {
|
||||||
|
currentPath = path.join(currentPath, segment);
|
||||||
|
try {
|
||||||
|
const stats = await lstat(currentPath);
|
||||||
|
if (stats.isSymbolicLink()) throw new Error(`${label} contains a symlink`);
|
||||||
|
} catch (error) {
|
||||||
|
if (missingPath(error)) break;
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return resolvedChild;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const assertSafeRunRoot = async (runRoot: string, options: { allowMissing?: boolean } = {}) =>
|
||||||
|
await assertSafeTemporaryChild(runRoot, {
|
||||||
|
allowMissing: options.allowMissing,
|
||||||
|
label: "The QA run-root",
|
||||||
|
namePattern: runRootNamePattern,
|
||||||
|
nameRequirement:
|
||||||
|
'The QA run-root basename must start with "homarr-release-v2-qa" and use only safe suffix characters',
|
||||||
|
});
|
||||||
|
|
||||||
|
export const assertSafeZoomOutput = async (outputDirectory: string, options: { allowMissing?: boolean } = {}) =>
|
||||||
|
await assertSafeTemporaryChild(outputDirectory, {
|
||||||
|
allowMissing: options.allowMissing,
|
||||||
|
label: "The zoom extension output",
|
||||||
|
namePattern: zoomOutputNamePattern,
|
||||||
|
nameRequirement:
|
||||||
|
'The zoom extension output basename must start with "homarr-release-v2-qa-zoom-" and use only safe suffix characters',
|
||||||
|
});
|
||||||
|
|
||||||
|
const readLinuxProcessStartMarker = async (pid: number) => {
|
||||||
|
if (process.platform !== "linux") return null;
|
||||||
|
try {
|
||||||
|
const processStat = await readFile(`/proc/${pid}/stat`, "utf8");
|
||||||
|
const commandEnd = processStat.lastIndexOf(")");
|
||||||
|
if (commandEnd < 0) return null;
|
||||||
|
const fieldsAfterCommand = processStat
|
||||||
|
.slice(commandEnd + 2)
|
||||||
|
.trim()
|
||||||
|
.split(/\s+/u);
|
||||||
|
const startTime = fieldsAfterCommand[19];
|
||||||
|
return startTime ? `linux:${startTime}` : null;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const isProcessRunning = (pid: number) => {
|
||||||
|
try {
|
||||||
|
process.kill(pid, 0);
|
||||||
|
return true;
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
interface SlotLeaseOwner {
|
||||||
|
schemaVersion: 1;
|
||||||
|
pid: number;
|
||||||
|
processStartMarker: string | null;
|
||||||
|
token: string;
|
||||||
|
runRoot: string;
|
||||||
|
slot: number;
|
||||||
|
operation: string;
|
||||||
|
acquiredAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface LeaseSnapshot {
|
||||||
|
device: bigint;
|
||||||
|
inode: bigint;
|
||||||
|
modifiedMs: number;
|
||||||
|
content: string;
|
||||||
|
owner: SlotLeaseOwner | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SlotLease {
|
||||||
|
path: string;
|
||||||
|
owner: SlotLeaseOwner;
|
||||||
|
release: () => Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const getSlotLeasePath = async (runRoot: string, slot: number) => {
|
||||||
|
const canonicalRunRoot = await assertSafeRunRoot(runRoot, { allowMissing: true });
|
||||||
|
const canonicalParent = await canonicalTemporaryParent();
|
||||||
|
return path.join(canonicalParent, `.${path.basename(canonicalRunRoot)}.slot-${slot}.lease`);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getBuildLeasePath = async (runRoot: string) => {
|
||||||
|
const canonicalRunRoot = await assertSafeRunRoot(runRoot, { allowMissing: true });
|
||||||
|
const canonicalParent = await canonicalTemporaryParent();
|
||||||
|
return path.join(canonicalParent, `.${path.basename(canonicalRunRoot)}.candidate-build.lease`);
|
||||||
|
};
|
||||||
|
|
||||||
|
const readLeaseSnapshot = async (leasePath: string): Promise<LeaseSnapshot> => {
|
||||||
|
const stats = await lstat(leasePath, { bigint: true });
|
||||||
|
if (stats.isSymbolicLink() || !stats.isFile()) {
|
||||||
|
throw new Error(`Refusing unsafe QA slot lease path: ${leasePath}`);
|
||||||
|
}
|
||||||
|
const content = await readFile(leasePath, "utf8");
|
||||||
|
let owner: SlotLeaseOwner | null = null;
|
||||||
|
try {
|
||||||
|
owner = JSON.parse(content) as SlotLeaseOwner;
|
||||||
|
} catch {
|
||||||
|
// An acquiring process may have created the file but not completed its marker write yet.
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
device: stats.dev,
|
||||||
|
inode: stats.ino,
|
||||||
|
modifiedMs: Number(stats.mtimeMs),
|
||||||
|
content,
|
||||||
|
owner,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const isValidLeaseOwner = (owner: SlotLeaseOwner | null, runRoot: string, slot: number): owner is SlotLeaseOwner =>
|
||||||
|
owner?.schemaVersion === leaseSchemaVersion &&
|
||||||
|
Number.isInteger(owner.pid) &&
|
||||||
|
owner.pid > 0 &&
|
||||||
|
typeof owner.token === "string" &&
|
||||||
|
owner.token.length > 0 &&
|
||||||
|
path.resolve(owner.runRoot) === runRoot &&
|
||||||
|
owner.slot === slot;
|
||||||
|
|
||||||
|
const processMatchesLeaseOwner = async (owner: SlotLeaseOwner) => {
|
||||||
|
if (!isProcessRunning(owner.pid)) return false;
|
||||||
|
const observedStartMarker = await readLinuxProcessStartMarker(owner.pid);
|
||||||
|
if (owner.processStartMarker && observedStartMarker) {
|
||||||
|
return owner.processStartMarker === observedStartMarker;
|
||||||
|
}
|
||||||
|
// If the platform cannot verify process start identity, preserve the live lock rather than
|
||||||
|
// risking recovery over an active owner.
|
||||||
|
return true;
|
||||||
|
};
|
||||||
|
|
||||||
|
const sameLeaseSnapshot = (left: LeaseSnapshot, right: LeaseSnapshot) =>
|
||||||
|
left.device === right.device &&
|
||||||
|
left.inode === right.inode &&
|
||||||
|
left.modifiedMs === right.modifiedMs &&
|
||||||
|
left.content === right.content;
|
||||||
|
|
||||||
|
const recoverStaleLease = async (leasePath: string, snapshot: LeaseSnapshot) => {
|
||||||
|
const confirmation = await readLeaseSnapshot(leasePath);
|
||||||
|
if (!sameLeaseSnapshot(snapshot, confirmation)) {
|
||||||
|
throw new Error(`QA slot lease changed during stale recovery: ${leasePath}`);
|
||||||
|
}
|
||||||
|
await unlink(leasePath);
|
||||||
|
};
|
||||||
|
|
||||||
|
const acquireLease = async (
|
||||||
|
runRoot: string,
|
||||||
|
slot: number,
|
||||||
|
operation: string,
|
||||||
|
leasePath: string,
|
||||||
|
resourceLabel: string,
|
||||||
|
platform: NodeJS.Platform = process.platform,
|
||||||
|
): Promise<SlotLease> => {
|
||||||
|
assertSupportedRuntimePlatform(platform);
|
||||||
|
const canonicalRunRoot = await assertSafeRunRoot(runRoot, { allowMissing: true });
|
||||||
|
const owner: SlotLeaseOwner = {
|
||||||
|
schemaVersion: leaseSchemaVersion,
|
||||||
|
pid: process.pid,
|
||||||
|
processStartMarker: await readLinuxProcessStartMarker(process.pid),
|
||||||
|
token: randomUUID(),
|
||||||
|
runRoot: canonicalRunRoot,
|
||||||
|
slot,
|
||||||
|
operation,
|
||||||
|
acquiredAt: new Date().toISOString(),
|
||||||
|
};
|
||||||
|
|
||||||
|
for (let attempt = 0; attempt < 2; attempt += 1) {
|
||||||
|
try {
|
||||||
|
const handle = await open(leasePath, "wx", 0o600);
|
||||||
|
try {
|
||||||
|
await handle.writeFile(`${JSON.stringify(owner)}\n`, "utf8");
|
||||||
|
await handle.sync();
|
||||||
|
} finally {
|
||||||
|
await handle.close();
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
path: leasePath,
|
||||||
|
owner,
|
||||||
|
release: async () => {
|
||||||
|
const snapshot = await readLeaseSnapshot(leasePath);
|
||||||
|
if (!isValidLeaseOwner(snapshot.owner, canonicalRunRoot, slot) || snapshot.owner?.token !== owner.token) {
|
||||||
|
throw new Error(`Refusing to release a QA ${resourceLabel} lease owned by another process: ${leasePath}`);
|
||||||
|
}
|
||||||
|
await recoverStaleLease(leasePath, snapshot);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
if (!existingPath(error)) throw error;
|
||||||
|
const snapshot = await readLeaseSnapshot(leasePath);
|
||||||
|
const existingOwner = snapshot.owner;
|
||||||
|
if (!isValidLeaseOwner(existingOwner, canonicalRunRoot, slot)) {
|
||||||
|
if (Date.now() - snapshot.modifiedMs < incompleteLeaseGraceMs) {
|
||||||
|
throw new Error(`QA ${resourceLabel} lease is being initialized by another operation`, { cause: error });
|
||||||
|
}
|
||||||
|
await recoverStaleLease(leasePath, snapshot);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (await processMatchesLeaseOwner(existingOwner)) {
|
||||||
|
throw new Error(
|
||||||
|
`QA ${resourceLabel} is busy with ${existingOwner.operation} owned by PID ${existingOwner.pid}`,
|
||||||
|
{
|
||||||
|
cause: error,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
await recoverStaleLease(leasePath, snapshot);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new Error(`Unable to acquire QA ${resourceLabel} lease after stale recovery`);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const acquireSlotLease = async (
|
||||||
|
runRoot: string,
|
||||||
|
slot: number,
|
||||||
|
operation: string,
|
||||||
|
platform: NodeJS.Platform = process.platform,
|
||||||
|
) => {
|
||||||
|
if (![1, 2, 3].includes(slot)) throw new Error("QA slot lease requires slot 1, 2, or 3");
|
||||||
|
const leasePath = await getSlotLeasePath(runRoot, slot);
|
||||||
|
return await acquireLease(runRoot, slot, operation, leasePath, `slot ${slot}`, platform);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const acquireBuildLease = async (
|
||||||
|
runRoot: string,
|
||||||
|
operation: string,
|
||||||
|
platform: NodeJS.Platform = process.platform,
|
||||||
|
) => {
|
||||||
|
const leasePath = await getBuildLeasePath(runRoot);
|
||||||
|
return await acquireLease(runRoot, 0, operation, leasePath, "candidate build", platform);
|
||||||
|
};
|
||||||
|
|
||||||
|
const normalizedHost = (host: string) => {
|
||||||
|
const lowerHost = host.toLowerCase();
|
||||||
|
if (lowerHost.startsWith("[") && lowerHost.endsWith("]")) return lowerHost.slice(1, -1);
|
||||||
|
return lowerHost;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const validateLoopbackHost = (host: string) => {
|
||||||
|
const normalized = normalizedHost(host);
|
||||||
|
if (normalized !== "127.0.0.1" && normalized !== "localhost" && !ipv6LoopbackHosts.has(normalized)) {
|
||||||
|
throw new Error("The fixture host must be an explicit loopback address");
|
||||||
|
}
|
||||||
|
return normalized;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const validateFixtureUrl = (value: string) => {
|
||||||
|
let fixtureUrl: URL;
|
||||||
|
try {
|
||||||
|
fixtureUrl = new URL(value);
|
||||||
|
} catch {
|
||||||
|
throw new Error("The fixture URL must be a valid loopback HTTP origin");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
fixtureUrl.protocol !== "http:" ||
|
||||||
|
fixtureUrl.username ||
|
||||||
|
fixtureUrl.password ||
|
||||||
|
fixtureUrl.pathname !== "/" ||
|
||||||
|
fixtureUrl.search ||
|
||||||
|
fixtureUrl.hash
|
||||||
|
) {
|
||||||
|
throw new Error("The fixture URL must be a credential-free loopback HTTP origin");
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
validateLoopbackHost(fixtureUrl.hostname);
|
||||||
|
} catch {
|
||||||
|
throw new Error("The fixture URL must be a credential-free loopback HTTP origin");
|
||||||
|
}
|
||||||
|
return fixtureUrl.origin;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const sanitizeAppEnvironment = (environment: NodeJS.ProcessEnv) => {
|
||||||
|
const sanitized = { ...environment };
|
||||||
|
delete sanitized.QA_PASSWORD;
|
||||||
|
delete sanitized.QA_PASSWORD_FILE;
|
||||||
|
return sanitized;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const createFixtureEnvironment = (runId: string): NodeJS.ProcessEnv => ({ QA_RUN_ID: runId });
|
||||||
@@ -0,0 +1,245 @@
|
|||||||
|
// @vitest-environment node
|
||||||
|
|
||||||
|
import { mkdir, realpath, rm, stat, symlink, writeFile } from "node:fs/promises";
|
||||||
|
import { tmpdir } from "node:os";
|
||||||
|
import path from "node:path";
|
||||||
|
import { afterEach, describe, expect, test } from "vitest";
|
||||||
|
|
||||||
|
import {
|
||||||
|
acquireBuildLease,
|
||||||
|
acquireSlotLease,
|
||||||
|
assertSafeContainedPath,
|
||||||
|
assertSafeRunRoot,
|
||||||
|
assertSupportedRuntimePlatform,
|
||||||
|
createFixtureEnvironment,
|
||||||
|
getSlotLeasePath,
|
||||||
|
isPathWithin,
|
||||||
|
sanitizeAppEnvironment,
|
||||||
|
validateFixtureUrl,
|
||||||
|
validateLoopbackHost,
|
||||||
|
} from "./safety.mts";
|
||||||
|
|
||||||
|
const temporaryPaths: string[] = [];
|
||||||
|
const uniqueName = (suffix: string) => `homarr-release-v2-qa-test-${process.pid}-${Date.now()}-${suffix}`;
|
||||||
|
|
||||||
|
afterEach(async () => {
|
||||||
|
await Promise.all(temporaryPaths.splice(0).map((target) => rm(target, { force: true, recursive: true })));
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("release-v2 QA harness safety", () => {
|
||||||
|
test("supports runtime mutations only on Linux", () => {
|
||||||
|
expect(() => assertSupportedRuntimePlatform("linux")).not.toThrow();
|
||||||
|
expect(() => assertSupportedRuntimePlatform("darwin")).toThrow(/Linux/u);
|
||||||
|
expect(() => assertSupportedRuntimePlatform("win32")).toThrow(/Linux/u);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("rejects an unsupported platform before creating a slot lease", async () => {
|
||||||
|
const runRoot = path.join(tmpdir(), uniqueName("unsupported-platform"));
|
||||||
|
const leasePath = await getSlotLeasePath(runRoot, 1);
|
||||||
|
temporaryPaths.push(leasePath);
|
||||||
|
|
||||||
|
await expect(acquireSlotLease(runRoot, 1, "unsupported-start", "darwin")).rejects.toThrow(/Linux/u);
|
||||||
|
await expect(stat(leasePath)).rejects.toMatchObject({ code: "ENOENT" });
|
||||||
|
});
|
||||||
|
|
||||||
|
test("accepts only direct, predictably named children of the temporary directory", async () => {
|
||||||
|
const safeRoot = path.join(tmpdir(), uniqueName("safe"));
|
||||||
|
temporaryPaths.push(safeRoot);
|
||||||
|
|
||||||
|
await expect(assertSafeRunRoot(safeRoot, { allowMissing: true })).resolves.toBe(path.resolve(safeRoot));
|
||||||
|
await expect(
|
||||||
|
assertSafeRunRoot(path.join(tmpdir(), "unrelated-homarr-release-v2-qa"), { allowMissing: true }),
|
||||||
|
).rejects.toThrow("must start with");
|
||||||
|
await expect(
|
||||||
|
assertSafeRunRoot(path.join(tmpdir(), "homarr-release-v2-qaevil"), { allowMissing: true }),
|
||||||
|
).rejects.toThrow("must start with");
|
||||||
|
await expect(
|
||||||
|
assertSafeRunRoot(path.join(tmpdir(), "nested", uniqueName("nested")), { allowMissing: true }),
|
||||||
|
).rejects.toThrow("direct child");
|
||||||
|
await expect(
|
||||||
|
assertSafeRunRoot(path.join(tmpdir(), "..", "var", "tmp", uniqueName("traversal")), { allowMissing: true }),
|
||||||
|
).rejects.toThrow("direct child");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("canonicalizes a temporary-directory alias without widening containment", async () => {
|
||||||
|
const temporaryAlias = path.join(tmpdir(), uniqueName("temporary-alias"));
|
||||||
|
const runRootName = uniqueName("canonical-root");
|
||||||
|
temporaryPaths.push(temporaryAlias);
|
||||||
|
await symlink(tmpdir(), temporaryAlias, "dir");
|
||||||
|
|
||||||
|
await expect(assertSafeRunRoot(path.join(temporaryAlias, runRootName), { allowMissing: true })).resolves.toBe(
|
||||||
|
path.join(await realpath(tmpdir()), runRootName),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("rejects a symlink run-root", async () => {
|
||||||
|
const target = path.join(tmpdir(), uniqueName("target"));
|
||||||
|
const linkedRoot = path.join(tmpdir(), uniqueName("linked"));
|
||||||
|
temporaryPaths.push(linkedRoot, target);
|
||||||
|
await mkdir(target);
|
||||||
|
await symlink(target, linkedRoot);
|
||||||
|
|
||||||
|
await expect(assertSafeRunRoot(linkedRoot)).rejects.toThrow("symlink");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("rejects symlink components in a contained reset target", async () => {
|
||||||
|
const runRoot = path.join(tmpdir(), uniqueName("component-root"));
|
||||||
|
const externalTarget = path.join(tmpdir(), uniqueName("component-target"));
|
||||||
|
temporaryPaths.push(runRoot, externalTarget);
|
||||||
|
await mkdir(runRoot);
|
||||||
|
await mkdir(externalTarget);
|
||||||
|
await symlink(externalTarget, path.join(runRoot, "slots"));
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
assertSafeContainedPath(runRoot, path.join(runRoot, "slots", "1"), "QA slot reset target"),
|
||||||
|
).rejects.toThrow("symlink");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("catches a symlink swapped in after an earlier missing-target check", async () => {
|
||||||
|
const runRoot = path.join(tmpdir(), uniqueName("swap-root"));
|
||||||
|
const externalTarget = path.join(tmpdir(), uniqueName("swap-target"));
|
||||||
|
const slotPath = path.join(runRoot, "slots", "1");
|
||||||
|
temporaryPaths.push(runRoot, externalTarget);
|
||||||
|
await mkdir(path.dirname(slotPath), { recursive: true });
|
||||||
|
await mkdir(externalTarget);
|
||||||
|
|
||||||
|
await expect(assertSafeContainedPath(runRoot, slotPath, "QA slot mutation target")).resolves.toBe(slotPath);
|
||||||
|
await symlink(externalTarget, slotPath);
|
||||||
|
await expect(assertSafeContainedPath(runRoot, slotPath, "QA slot mutation target")).rejects.toThrow("symlink");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("rejects a concurrent operation while a live slot lease is held", async () => {
|
||||||
|
const runRoot = path.join(tmpdir(), uniqueName("live-lease"));
|
||||||
|
const lease = await acquireSlotLease(runRoot, 1, "first-start");
|
||||||
|
temporaryPaths.push(lease.path);
|
||||||
|
|
||||||
|
await expect(acquireSlotLease(runRoot, 1, "second-start")).rejects.toThrow(/slot 1 is busy.*first-start/u);
|
||||||
|
await lease.release();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("serializes the shared candidate build independently of slot leases", async () => {
|
||||||
|
const runRoot = path.join(tmpdir(), uniqueName("build-lease"));
|
||||||
|
const buildLease = await acquireBuildLease(runRoot, "first-build");
|
||||||
|
const slotLease = await acquireSlotLease(runRoot, 1, "slot-start");
|
||||||
|
temporaryPaths.push(buildLease.path, slotLease.path);
|
||||||
|
|
||||||
|
await expect(acquireBuildLease(runRoot, "second-build")).rejects.toThrow(/candidate build is busy.*first-build/u);
|
||||||
|
await buildLease.release();
|
||||||
|
await slotLease.release();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("recovers a stale lease with a dead owner PID", async () => {
|
||||||
|
const runRoot = path.join(tmpdir(), uniqueName("stale-lease"));
|
||||||
|
const leasePath = await getSlotLeasePath(runRoot, 2);
|
||||||
|
temporaryPaths.push(leasePath);
|
||||||
|
await writeFile(
|
||||||
|
leasePath,
|
||||||
|
`${JSON.stringify({
|
||||||
|
schemaVersion: 1,
|
||||||
|
pid: 2_147_483_647,
|
||||||
|
processStartMarker: "linux:stale",
|
||||||
|
token: "stale-token",
|
||||||
|
runRoot: path.resolve(runRoot),
|
||||||
|
slot: 2,
|
||||||
|
operation: "stale-start",
|
||||||
|
acquiredAt: "2026-01-01T00:00:00.000Z",
|
||||||
|
})}\n`,
|
||||||
|
{ mode: 0o600 },
|
||||||
|
);
|
||||||
|
|
||||||
|
const lease = await acquireSlotLease(runRoot, 2, "replacement-start");
|
||||||
|
expect(lease.owner.operation).toBe("replacement-start");
|
||||||
|
await lease.release();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("recovers a PID-reuse lease marker without signaling the unrelated process", async () => {
|
||||||
|
const runRoot = path.join(tmpdir(), uniqueName("pid-reuse-lease"));
|
||||||
|
const leasePath = await getSlotLeasePath(runRoot, 3);
|
||||||
|
temporaryPaths.push(leasePath);
|
||||||
|
await writeFile(
|
||||||
|
leasePath,
|
||||||
|
`${JSON.stringify({
|
||||||
|
schemaVersion: 1,
|
||||||
|
pid: process.pid,
|
||||||
|
processStartMarker: "linux:not-this-process",
|
||||||
|
token: "reused-pid-token",
|
||||||
|
runRoot: path.resolve(runRoot),
|
||||||
|
slot: 3,
|
||||||
|
operation: "abandoned-start",
|
||||||
|
acquiredAt: "2026-01-01T00:00:00.000Z",
|
||||||
|
})}\n`,
|
||||||
|
{ mode: 0o600 },
|
||||||
|
);
|
||||||
|
|
||||||
|
const lease = await acquireSlotLease(runRoot, 3, "safe-recovery");
|
||||||
|
expect(process.pid).toBeGreaterThan(0);
|
||||||
|
await lease.release();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("uses path segments rather than string prefixes for containment", () => {
|
||||||
|
const parent = path.join(tmpdir(), "qa-artifacts", "packet-1");
|
||||||
|
expect(isPathWithin(parent, path.join(parent, "screen.png"))).toBe(true);
|
||||||
|
expect(isPathWithin(parent, `${parent}-spoof/screen.png`)).toBe(false);
|
||||||
|
expect(isPathWithin(parent, path.join(parent, "..", "packet-2", "screen.png"))).toBe(false);
|
||||||
|
expect(isPathWithin(parent, ".screenshots/release-v2/packet-1/screen.png")).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test.each(["http://127.0.0.1:3210", "http://localhost:3210", "http://[::1]:3210", "http://[0:0:0:0:0:0:0:1]:3210"])(
|
||||||
|
"accepts a credential-free loopback fixture origin: %s",
|
||||||
|
(fixtureUrl) => {
|
||||||
|
expect(validateFixtureUrl(fixtureUrl)).toBe(new URL(fixtureUrl).origin);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
test.each([
|
||||||
|
"https://127.0.0.1:3210",
|
||||||
|
"http://user:secret@127.0.0.1:3210",
|
||||||
|
"http://0.0.0.0:3210",
|
||||||
|
"http://192.0.2.1:3210",
|
||||||
|
"http://localhost.example:3210",
|
||||||
|
"http://127.0.0.1:3210/path",
|
||||||
|
])("rejects an unsafe fixture URL without echoing it: %s", (fixtureUrl) => {
|
||||||
|
expect(() => validateFixtureUrl(fixtureUrl)).toThrow("fixture URL");
|
||||||
|
});
|
||||||
|
|
||||||
|
test.each(["127.0.0.1", "localhost", "::1", "[::1]", "0:0:0:0:0:0:0:1"])(
|
||||||
|
"accepts the loopback fixture bind host %s",
|
||||||
|
(host) => {
|
||||||
|
expect(validateLoopbackHost(host)).toBeTruthy();
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
test.each(["0.0.0.0", "::", "192.0.2.1", "example.test"])("rejects the non-loopback bind host %s", (host) => {
|
||||||
|
expect(() => validateLoopbackHost(host)).toThrow("loopback");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("removes both QA password inputs from the spawned app environment", () => {
|
||||||
|
const environment = sanitizeAppEnvironment({
|
||||||
|
QA_PASSWORD: "do-not-forward",
|
||||||
|
QA_PASSWORD_FILE: "/tmp/do-not-forward",
|
||||||
|
QA_RUN_ID: "qa-run",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(environment).toEqual({ QA_RUN_ID: "qa-run" });
|
||||||
|
expect("QA_PASSWORD" in environment).toBe(false);
|
||||||
|
expect("QA_PASSWORD_FILE" in environment).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("uses an explicit fixture environment allowlist", () => {
|
||||||
|
const environment = createFixtureEnvironment("qa-run");
|
||||||
|
|
||||||
|
expect(environment).toEqual({ QA_RUN_ID: "qa-run" });
|
||||||
|
for (const forbiddenName of [
|
||||||
|
"QA_PASSWORD",
|
||||||
|
"QA_PASSWORD_FILE",
|
||||||
|
"DB_URL",
|
||||||
|
"AUTH_SECRET",
|
||||||
|
"API_KEY",
|
||||||
|
"SECRET_ENCRYPTION_KEY",
|
||||||
|
"HOME",
|
||||||
|
"PATH",
|
||||||
|
]) {
|
||||||
|
expect(forbiddenName in environment).toBe(false);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
File diff suppressed because it is too large.
Load diff
Reference in new issue
Block a user