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
|
||||
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.
|
||||
|
||||

|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
@@ -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">
|
||||
|
||||
@@ -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: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;
|
||||
}
|
||||
@@ -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">) =>
|
||||
|
||||
@@ -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}
|
||||
|
||||
Generated
-9
@@ -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
|
||||
|
||||
@@ -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