Compare commits

...
Author SHA1 Message Date
ajnart 2a4658d255 test: add release v2 mega QA seeder 2026-09-01 11:01:01 +02:00
Bergasha ac62e5bfd6 fix(system-resources): divide chart rows evenly via CSS, no board-zoom pixel math (#6756)
Each visible chart row was hardcoded to a fixed pixel height (110px in
advanced mode, 56px-capped in compact) regardless of the widget's actual
tile size - resizing the tile taller left the extra height unused, and
some visible charts required scrolling to reach.

Divide the available height evenly across rows using CSS Grid's
grid-auto-rows: 1fr (the Stack fills the tile via a definite pixel
height, the SimpleGrid grows into it via flex and divides its rows
evenly) rather than computing a per-chart pixel height in JS. Confirmed
via live inspection of a deployed board that Mantine's h prop is not
safe for this: its rem() converter strips a "px" suffix from any string
before checking whether it's a parseable number, so even a literal pixel
*string* passed through h= gets rescaled by rem() * var(--mantine-scale)
exactly like a raw number would - style.height is the only way to set
an exact, un-rescaled pixel value.
2026-09-01 16:50:00 +10:00
Thomas f57a660088 feat: add global server custom CSS (#6753)
* feat: add global server custom CSS

* refactor: reuse CodeMirror for CSS editors
2026-08-31 14:26:58 +02:00
29 changed files with 2972 additions and 374 deletions

No files matched your search

+5 -2
View File
@@ -85,8 +85,11 @@ To do this, enter the board settings:
![](./img/enter-board-settings.png)
Scroll to the **Custom CSS** section on the board settings page. It is part of the unified settings form; enter your CSS
there and save the board settings to apply it to that board.
Administrators can add instance-wide CSS under **Management → Settings → Branding → Custom CSS**. Enter CSS directly
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.
![](./img/custom-css-setting.png)
@@ -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.
### 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
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.
-3
View File
@@ -106,12 +106,10 @@
"next": "catalog:",
"postcss-preset-mantine": "catalog:",
"posthog-js": "catalog:",
"prismjs": "catalog:",
"react": "catalog:",
"react-dom": "catalog:",
"react-error-boundary": "catalog:",
"react-shiki": "catalog:",
"react-simple-code-editor": "catalog:",
"remark-breaks": "catalog:",
"remark-directive": "catalog:",
"remark-gfm": "catalog:",
@@ -128,7 +126,6 @@
"@types/better-sqlite3": "catalog:",
"@types/chroma-js": "catalog:",
"@types/node": "catalog:",
"@types/prismjs": "catalog:",
"@types/react": "catalog:",
"@types/react-dom": "catalog:",
"concurrently": "catalog:",
@@ -1,21 +1,16 @@
"use client";
import { Alert, Input } from "@mantine/core";
import { highlight, languages } from "prismjs";
import Editor from "react-simple-code-editor";
import "~/styles/prismjs.scss";
import { Alert } from "@mantine/core";
import { IconInfoCircle } from "@tabler/icons-react";
import { useSession } from "@homarr/auth/client";
import type { UseFormReturnType } from "@homarr/form";
import { useI18n } from "@homarr/translation/client";
import { CustomCssEditor } from "~/components/custom-css/custom-css-editor";
import { SectionCard } from "~/components/manage/section-card";
import { WorkshopCssImportButton } from "~/components/workshop/workshop-css-import-button";
import type { FormValues } from "./_settings-form";
import classes from "./customcss.module.css";
interface Props {
form: UseFormReturnType<FormValues>;
@@ -29,7 +24,12 @@ export const CustomCssSettingsContent = ({ form }: Props) => {
return (
<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 />}>
{customCssT("customClassesAlert.description")}
@@ -38,40 +38,3 @@ export const CustomCssSettingsContent = ({ form }: Props) => {
</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));
}
+1
View File
@@ -199,6 +199,7 @@ export default async function Layout(props: {
) : null}
<SearchEngineOptimization />
<CrowdinLiveTranslation locale={locale} />
<style data-homarr-global-custom-css>{serverSettings.branding.customCss}</style>
</head>
<body className={[fontSans.className, fontSans.variable].join(" ")} suppressHydrationWarning>
<Analytics enabled={serverSettings.analytics.enableGeneral} />
@@ -1,50 +1 @@
"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} />;
}
export { CodeEditor } from "~/components/custom-widgets/code-editor";
@@ -20,7 +20,9 @@ import type { UseFormReturnType } from "@homarr/form";
import { useI18n } from "@homarr/translation/client";
import { BoardColorInput, CornerStylePicker, cornerStyleValues } from "@homarr/ui";
import { CustomCssEditor } from "~/components/custom-css/custom-css-editor";
import { SectionCard } from "~/components/manage/section-card";
import { WorkshopCssImportButton } from "~/components/workshop/workshop-css-import-button";
import type { FormValues } from "./settings-form";
interface BrandingSettingsFormProps {
@@ -101,6 +103,21 @@ export const BrandingSettingsForm = ({ form }: BrandingSettingsFormProps) => {
</SimpleGrid>
</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">
<Stack gap="sm">
<SimpleGrid cols={{ base: 1, md: 2 }} spacing="sm" verticalSpacing="sm">
@@ -1,6 +1,7 @@
"use client";
import { useEffect, useRef, useState } from "react";
import { useRouter } from "next/navigation";
import { Button, Stack } from "@mantine/core";
import { z } from "zod/v4";
@@ -70,6 +71,7 @@ interface SettingsFormProps {
export const SettingsForm = ({ initialData, selectableBoards, selectableSearchEngines }: SettingsFormProps) => {
const tCommon = useI18n("common");
const tSettings = useI18n("management.page.settings");
const router = useRouter();
const initialValues = buildInitialValues(initialData);
const initialValuesRef = useRef(initialValues);
@@ -164,6 +166,7 @@ export const SettingsForm = ({ initialData, selectableBoards, selectableSearchEn
form.setInitialValues(values);
form.resetDirty();
await revalidatePathActionAsync("/manage/settings");
router.refresh();
showSuccessNotification({
title: tCommon("notification.update.success"),
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";
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 { useDebouncedValue, useDisclosure } from "@mantine/hooks";
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.
* 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 [opened, controls] = useDisclosure(false);
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")}
</Button>
<Modal opened={opened} onClose={controls.close} title={t("importCss")} size={modalSizeSelect} radius="md">
-225
View File
@@ -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;
}
}
+2
View File
@@ -44,6 +44,8 @@
"lint:fix": "turbo lint --continue -- --fix",
"lint:ws": "pnpm dlx sherif@latest",
"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",
"scripts:update-bug-report-template": "tsx ./scripts/update-bug-report-template.mts",
"scripts:update-readme-integrations": "tsx ./scripts/update-integration-list.mts",
@@ -54,6 +54,7 @@ export function CodeEditorToolbar({
</Text>
</Group>
<Group gap={6}>
{props.actions}
{!props.readOnly && (
<>
<Button
@@ -57,4 +57,5 @@ export interface CustomWidgetCodeEditorProps {
readOnly?: boolean;
reference?: CustomWidgetSchemaReferenceData;
height?: string;
actions?: ReactNode;
}
+2
View File
@@ -51,6 +51,7 @@ export const brandingServerSettingsSchema = z.object({
signInBackgroundOverlay: z.number().min(0).max(0.9),
authBranding: authBrandingSchema,
defaultRadius: z.enum(brandingRadiusOptions),
customCss: z.string().max(16_384),
});
export type BrandingSettings = z.infer<typeof brandingServerSettingsSchema>;
@@ -71,6 +72,7 @@ export const defaultBrandingSettings: BrandingSettings = {
showGreeting: true,
},
defaultRadius: "md",
customCss: "",
};
const hasDefaultBrandingColors = (branding: Pick<BrandingSettings, "primaryColor" | "secondaryColor">) =>
+5
View File
@@ -5972,6 +5972,7 @@
"groups": {
"identity": "Identity",
"appearance": "Appearance",
"customCss": "Custom CSS",
"authentication": "Authentication"
},
"identity": {
@@ -6018,6 +6019,10 @@
"xl": "Soft"
}
},
"customCss": {
"label": "Global custom CSS",
"description": "Applied to every page. Board custom CSS can override it."
},
"authVisibility": {
"description": "Choose what appears on every authentication page.",
"appName": "Application name",
@@ -236,23 +236,26 @@ const SystemCharts = ({
const labelDisplayMode = isAdvanced ? "textWithIcon" : options.labelDisplayMode;
const chartCount = visibleCharts.length;
const chartColumns = useMemo(() => (isAdvanced ? getAdvancedChartColumns(width) : 1), [isAdvanced, width]);
const compactChartGap = 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)),
);
const chartGap = isAdvanced ? "xs" : 8;
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 && (
<Text size="sm" fw={600} truncate="end">
{integrationName}
</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 && (
<Center h="100%">
<Text size="sm" c="dimmed">
@@ -261,7 +264,7 @@ const SystemCharts = ({
</Center>
)}
{visibleCharts.includes("cpu") && (
<Box h={chartHeight}>
<Box h="100%">
<SystemResourceCPUChart
cpuUsageOverTime={items.map((item) => item.cpu)}
hasShadow={options.hasShadow}
@@ -271,7 +274,7 @@ const SystemCharts = ({
</Box>
)}
{visibleCharts.includes("memory") && (
<Box h={chartHeight}>
<Box h="100%">
<SystemResourceMemoryChart
memoryUsageOverTime={items.map((item) => item.memory)}
totalCapacityInBytes={memoryCapacityInBytes}
@@ -282,7 +285,7 @@ const SystemCharts = ({
</Box>
)}
{visibleCharts.includes("gpu") && (
<Box h={chartHeight}>
<Box h="100%">
<SystemResourceGPUChart
gpuUsageOverTime={items.map((item) => item.gpu)}
hasShadow={options.hasShadow}
@@ -293,7 +296,7 @@ const SystemCharts = ({
)}
{showNetwork &&
(width >= 300 ? (
<Group h={chartHeight} gap="xs" grow wrap="nowrap">
<Group h="100%" gap="xs" grow wrap="nowrap">
<NetworkTrafficChart
usageOverTime={networkItems.map((network) => network.down)}
isUp={false}
@@ -310,7 +313,7 @@ const SystemCharts = ({
/>
</Group>
) : (
<Box h={chartHeight}>
<Box h="100%">
<CombinedNetworkTrafficChart
usageOverTime={networkItems}
hasShadow={options.hasShadow}
-9
View File
@@ -1238,9 +1238,6 @@ importers:
posthog-js:
specifier: 'catalog:'
version: 1.399.2(preact-render-to-string@6.5.11)
prismjs:
specifier: 'catalog:'
version: 1.30.0
react:
specifier: 'catalog:'
version: 19.2.7
@@ -1253,9 +1250,6 @@ importers:
react-shiki:
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)
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:
specifier: 'catalog:'
version: 4.0.0
@@ -1299,9 +1293,6 @@ importers:
'@types/node':
specifier: 'catalog:'
version: 24.13.3
'@types/prismjs':
specifier: 'catalog:'
version: 1.26.6
'@types/react':
specifier: 'catalog:'
version: 19.2.17
+48
View File
@@ -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.
+349
View File
@@ -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();
+152
View File
@@ -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;
};
+119
View File
@@ -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);
});
});
+76
View File
@@ -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);
};
+133
View File
@@ -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" });
});
});
+378
View File
@@ -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 });
+245
View File
@@ -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