feat!: release v2 (#6545)

Co-authored-by: Artur <145776561+arturict@users.noreply.github.com>
Co-authored-by: arturict <arturict@users.noreply.github.com>
Co-authored-by: Thomas <thomas@ajnart.fr>
Co-authored-by: Afonso Jorge Ramos <afonsojorgeramos@gmail.com>
Co-authored-by: Danura30082 <danura30082+github@gmail.com>
Co-authored-by: Kushie <122986913+kushiemoon-dev@users.noreply.github.com>
Co-authored-by: ajnart <thomas@ajnart.fr>
Co-authored-by: ajnart <thomas@ajnart.dev>
Co-authored-by: chenjun <chenjun@foxmail.com>
Co-authored-by: Wikibear <wikibear@users.noreply.github.com>
Co-authored-by: Bergasha <bergasha@gmail.com>
Co-authored-by: leafyelin <43306196+leafyelin@users.noreply.github.com>
Co-authored-by: Lucas Araujo <lucas.lra@gmail.com>
Co-authored-by: GreenMonito <86085570+GreenMonito@users.noreply.github.com>
Co-authored-by: Nerdy-Technician <roffo@nerdy-technician.social>
Co-authored-by: Jun <34545896+jcstrong@users.noreply.github.com>
Co-authored-by: Edmond Abraham <54119952+gitedmond@users.noreply.github.com>
Co-authored-by: thrawnx <tight.bulb9384@fastmail.com>
Co-authored-by: Chris <58918656+nordada@users.noreply.github.com>
Co-authored-by: yi111 <153097222+Yi-111-a@users.noreply.github.com>
Co-authored-by: Yanis <naamaneyanis@gmail.com>
Co-authored-by: Wikibear <me@sej.de>
Co-authored-by: alessiottavo <93441886+alessiottavo@users.noreply.github.com>
This commit is contained in:
3286 files changed
+284386 -139176

No files matched your search

+496
View File
@@ -0,0 +1,496 @@
#!/usr/bin/env bash
set -euo pipefail
BASE_URL="${1:-${BASE_URL:-http://100.111.30.70:3201}}"
SCREENSHOT_DIR="${2:-${SCREENSHOT_DIR:-.screenshots/ux-coherence-browser-agent}}"
BASE_URL="${BASE_URL%/}"
mkdir -p "$SCREENSHOT_DIR"
SCREENSHOT_DIR="$(cd "$SCREENSHOT_DIR" && pwd)"
SESSION="$(mise exec -- agent-browser session id --scope worktree --prefix ux-coherence)"
BOARD_NAME=""
BOARD_CREATE_REQUESTED=0
BOARD_CREATED=0
BOARD_CLEANUP_PENDING=0
API_KEY_CLEANUP_PENDING=0
API_KEY_CREATED_ID=""
CLEANUP_RAN=0
CLEANUP_DEADLINE=0
declare -a API_KEY_IDS_BEFORE=()
declare -A API_KEY_IDS_BEFORE_SET=()
API_KEY_IDS_BEFORE_JSON="[]"
ab() {
mise exec -- agent-browser --session "$SESSION" "$@"
}
fail() {
echo "ux-coherence browser assertion failed: $*" >&2
exit 1
}
assert_count() {
local selector="$1"
local expected="$2"
local message="$3"
local actual
actual="$(ab get count "$selector")"
if [[ "$actual" != "$expected" ]]; then
fail "$message (expected $expected, got $actual)"
fi
}
assert_no_dialog() {
local description="$1"
assert_eval "(() => { const dialog = Array.from(document.querySelectorAll('[role=\"dialog\"]')).find((element) => element.offsetParent !== null); if (dialog) throw new Error('$description opened a visible dialog'); return true; })()"
}
assert_no_modal_dialog() {
local description="$1"
assert_eval "(() => { const modal = Array.from(document.querySelectorAll('[role=\"dialog\"][aria-modal=\"true\"]')).find((element) => element.offsetParent !== null); if (modal) throw new Error('$description opened a visible modal dialog'); return true; })()"
}
assert_eval() {
local expression="$1"
ab eval "$expression" >/dev/null
}
parse_api_key_ids() {
local -n target="$1"
local remaining="$2"
local marker
local id
target=()
while [[ "$remaining" =~ UX_COHERENCE_API_ID=([[:xdigit:]]{8}) ]]; do
marker="${BASH_REMATCH[0]}"
id="${BASH_REMATCH[1],,}"
target+=("$id")
remaining="${remaining#*"$marker"}"
done
}
capture_api_key_ids() {
local target_name="$1"
local -n target="$target_name"
local output
local count
output="$(ab eval "Array.from(document.querySelectorAll('button[aria-label=\"Delete API token\"]')).map((button) => 'UX_COHERENCE_API_ID=' + button.closest('tr')?.querySelector('td')?.textContent?.trim()).join('\\n')")" || return
count="$(ab get count 'button[aria-label="Delete API token"]')" || return
if [[ ! "$count" =~ ^[0-9]+$ ]]; then
return 1
fi
parse_api_key_ids "$target_name" "$output"
[[ "${#target[@]}" -eq "$count" ]]
}
capture_cleanup_api_key_ids() {
local target_name="$1"
local -n target="$target_name"
local output
local count
output="$(cleanup_ab eval "Array.from(document.querySelectorAll('button[aria-label=\"Delete API token\"]')).map((button) => 'UX_COHERENCE_API_ID=' + button.closest('tr')?.querySelector('td')?.textContent?.trim()).join('\\n')")" || return
count="$(cleanup_ab get count 'button[aria-label="Delete API token"]')" || return
if [[ ! "$count" =~ ^[0-9]+$ ]]; then
return 1
fi
parse_api_key_ids "$target_name" "$output"
[[ "${#target[@]}" -eq "$count" ]]
}
api_key_ids_match_baseline() {
local -n current="$1"
local id
if [[ "${#current[@]}" -ne "${#API_KEY_IDS_BEFORE[@]}" ]]; then
return 1
fi
for id in "${current[@]}"; do
if [[ -z "${API_KEY_IDS_BEFORE_SET[$id]+present}" ]]; then
return 1
fi
done
}
api_key_ids_to_json() {
local -n ids="$1"
local separator=""
local id
printf '['
for id in "${ids[@]}"; do
printf '%s"%s"' "$separator" "$id"
separator=","
done
printf ']'
}
capture() {
local path="$1"
# Development tooling is not part of Homarr and obscures the reviewed UI.
ab eval "document.querySelectorAll('nextjs-portal, button[aria-label=\"Open TanStack Devtools\"]').forEach((element) => element.style.setProperty('display', 'none', 'important'))" >/dev/null
ab screenshot --full "$path"
}
retry_action_until() {
local action="$1"
local condition="$2"
local description="$3"
local result
for _ in {1..240}; do
result="$(ab eval "$condition" 2>/dev/null || true)"
if [[ "$result" == "true" ]]; then
return
fi
ab eval "$action" >/dev/null 2>&1 || true
ab wait 250 >/dev/null
done
fail "$description"
}
open_expandable() {
local selector="$1"
local description="$2"
retry_action_until \
"document.querySelector('$selector')?.click()" \
"document.querySelector('$selector')?.getAttribute('aria-expanded') === 'true'" \
"$description did not expand"
}
cleanup_ab() {
local remaining=$((CLEANUP_DEADLINE - SECONDS))
local command_timeout
if ((remaining <= 0)); then
return 124
fi
command_timeout="$remaining"
if ((command_timeout > 10)); then
command_timeout=10
fi
AGENT_BROWSER_DEFAULT_TIMEOUT="$((command_timeout * 1000))" \
timeout --signal=TERM "${command_timeout}s" mise exec -- agent-browser --session "$SESSION" "$@"
}
cleanup_board_resource() {
local opened
if ((BOARD_CREATED)); then
echo "Best-effort cleanup: confirmed board $BOARD_NAME" >&2
else
echo "Best-effort cleanup: board creation request for $BOARD_NAME" >&2
fi
cleanup_ab open "$BASE_URL/manage/boards" >/dev/null 2>&1 || return
cleanup_ab wait --load domcontentloaded >/dev/null 2>&1 || return
cleanup_ab wait --fn "document.querySelector('main') !== null" >/dev/null 2>&1 || return
opened="$(cleanup_ab eval "(() => { const card = Array.from(document.querySelectorAll('main .mantine-Card-root')).find((item) => item.querySelector('a[href=\"/boards/$BOARD_NAME\"]')); if (!card) return false; const settings = card.querySelector('button[aria-label=\"Settings\"]'); if (!settings) throw new Error('Board settings button was not found'); settings.click(); return true; })()" 2>/dev/null)" || return
if [[ "$opened" != "true" ]]; then
BOARD_CLEANUP_PENDING=0
BOARD_CREATED=0
return
fi
cleanup_ab wait --fn "Array.from(document.querySelectorAll('[role=\"menuitem\"]')).some((item) => item.offsetParent !== null && item.textContent?.trim() === 'Delete')" >/dev/null 2>&1 || return
cleanup_ab eval "(() => { const item = Array.from(document.querySelectorAll('[role=\"menuitem\"]')).find((element) => element.offsetParent !== null && element.textContent?.trim() === 'Delete'); if (!item) throw new Error('Board Delete action was not found'); item.click(); return true; })()" >/dev/null 2>&1 || return
cleanup_ab wait --fn "Array.from(document.querySelectorAll('[role=\"menuitem\"]')).some((item) => item.offsetParent !== null && item.textContent?.trim() === 'Confirm')" >/dev/null 2>&1 || return
cleanup_ab eval "(() => { const item = Array.from(document.querySelectorAll('[role=\"menuitem\"]')).find((element) => element.offsetParent !== null && element.textContent?.trim() === 'Confirm'); if (!item) throw new Error('Board Confirm action was not found'); item.click(); return true; })()" >/dev/null 2>&1 || return
cleanup_ab wait --fn "!document.body.innerText.includes('$BOARD_NAME')" >/dev/null 2>&1 || return
BOARD_CLEANUP_PENDING=0
BOARD_CREATED=0
}
cleanup_api_key_resources() {
local id
local -a current_ids=()
local -a created_ids=()
local -a remaining_ids=()
echo "Best-effort cleanup: API keys created after the captured baseline" >&2
cleanup_ab open "$BASE_URL/manage/tools/api" >/dev/null 2>&1 || return
cleanup_ab wait --load domcontentloaded >/dev/null 2>&1 || return
cleanup_ab wait --fn "Array.from(document.querySelectorAll('[role=\"tab\"]')).some((item) => item.textContent?.trim() === 'Authentication')" >/dev/null 2>&1 || return
cleanup_ab eval "Array.from(document.querySelectorAll('[role=\"tab\"]')).find((item) => item.textContent?.trim() === 'Authentication')?.click()" >/dev/null 2>&1 || return
cleanup_ab wait --fn "Array.from(document.querySelectorAll('h1, h2, h3')).some((heading) => heading.offsetParent !== null && heading.textContent?.trim() === 'API Keys')" >/dev/null 2>&1 || return
capture_cleanup_api_key_ids current_ids || return
if [[ -n "$API_KEY_CREATED_ID" ]]; then
for id in "${current_ids[@]}"; do
if [[ "$id" == "$API_KEY_CREATED_ID" ]]; then
created_ids+=("$id")
fi
done
else
for id in "${current_ids[@]}"; do
if [[ -z "${API_KEY_IDS_BEFORE_SET[$id]+present}" ]]; then
created_ids+=("$id")
fi
done
fi
for id in "${created_ids[@]}"; do
cleanup_ab eval "(() => { const id = '$id'; const button = Array.from(document.querySelectorAll('button[aria-label=\"Delete API token\"]')).find((item) => item.closest('tr')?.querySelector('td')?.textContent?.trim().toLowerCase() === id); if (!button) throw new Error('Created API key row was not found'); button.click(); return true; })()" >/dev/null 2>&1 || return
cleanup_ab wait --fn "(() => { const id = '$id'; const row = Array.from(document.querySelectorAll('tr')).find((item) => item.querySelector('td')?.textContent?.trim().toLowerCase() === id); return row?.querySelector('button[aria-label=\"Confirm\"]')?.offsetParent !== null; })()" >/dev/null 2>&1 || return
cleanup_ab eval "(() => { const id = '$id'; const row = Array.from(document.querySelectorAll('tr')).find((item) => item.querySelector('td')?.textContent?.trim().toLowerCase() === id); const button = row?.querySelector('button[aria-label=\"Confirm\"]'); if (!button) throw new Error('Created API key Confirm action was not found'); button.click(); return true; })()" >/dev/null 2>&1 || return
cleanup_ab wait --fn "!Array.from(document.querySelectorAll('tr')).some((item) => item.querySelector('td')?.textContent?.trim().toLowerCase() === '$id')" >/dev/null 2>&1 || return
done
capture_cleanup_api_key_ids remaining_ids || return
if api_key_ids_match_baseline remaining_ids; then
API_KEY_CLEANUP_PENDING=0
API_KEY_CREATED_ID=""
fi
}
cleanup() {
local exit_code=$?
if ((CLEANUP_RAN)); then
exit "$exit_code"
fi
CLEANUP_RAN=1
trap - EXIT INT TERM
set +e
CLEANUP_DEADLINE=$((SECONDS + 45))
if ((API_KEY_CLEANUP_PENDING)); then
cleanup_api_key_resources
fi
if ((BOARD_CLEANUP_PENDING && BOARD_CREATE_REQUESTED)); then
cleanup_board_resource
fi
# Closing the session is always the final browser operation.
timeout --signal=TERM 5s mise exec -- agent-browser --session "$SESSION" close >/dev/null 2>&1
exit "$exit_code"
}
trap cleanup EXIT
trap 'exit 130' INT
trap 'exit 143' TERM
echo "Browser session: $SESSION"
echo "Base URL: $BASE_URL"
echo "Screenshots: $SCREENSHOT_DIR"
# The first command launches Chromium. This host requires the sandbox override.
ab --args "--no-sandbox" open "$BASE_URL"
ab wait --load domcontentloaded
ab set viewport 1440 1000
ab set media light reduced-motion
ab wait --fn "window.location.pathname.includes('/auth/login') || document.querySelector('button[aria-label=\"Edit board\"]') !== null" --timeout 120000
current_url="$(ab get url)"
if [[ "$current_url" == *"/auth/login"* ]]; then
# These credentials are visibly documented by the writable demo login page.
ab wait --text "Demo mode is enabled"
ab wait --fn "(() => { const input = document.querySelector('input[name=\"username\"]'); return input !== null && Object.keys(input).some((key) => key.startsWith('__reactProps$')); })()" --timeout 120000
ab snapshot -i
ab find label "Username" fill "demo" --exact
ab find label "Password" fill "demo" --exact
ab find role button click --name "Login" --exact
ab wait --fn "!window.location.pathname.includes('/auth/login')" --timeout 120000
ab snapshot -i
fi
current_url="$(ab get url)"
if [[ "$current_url" == *"/auth/login"* ]]; then
fail "demo login did not leave the login page"
fi
navigate() {
local path="$1"
ab open "$BASE_URL$path"
ab wait --load domcontentloaded
ab snapshot -i
}
echo "[1/5] Weather dimensions popover"
navigate "/widgets/weather"
ab wait 'button[aria-label="Change dimensions"]' --timeout 120000
open_expandable 'button[aria-label="Change dimensions"]' "Weather dimensions popover"
ab wait --text "Width"
ab snapshot -i
# Mantine gives non-modal popovers a dialog role; verify that no modal shell opened.
assert_no_modal_dialog "Weather dimensions popover"
capture "$SCREENSHOT_DIR/01-weather-dimensions-popover.png"
assert_eval "(() => { const button = Array.from(document.querySelectorAll('button')).find((item) => item.textContent?.trim() === 'Cancel'); if (!button) throw new Error('Weather dimensions Cancel button was not found'); button.click(); return true; })()"
ab snapshot -i
echo "[2/5] Board creation and inline cleanup"
BOARD_NAME="ux-$(date +%Y%m%d%H%M%S)-$BASHPID"
navigate "/manage/boards"
ab wait --fn "(() => { const button = Array.from(document.querySelectorAll('button')).find((item) => item.textContent?.trim() === 'New board'); return button !== undefined && Object.keys(button).some((key) => key.startsWith('__reactProps$')); })()" --timeout 120000
ab find role button click --name "New board" --exact
ab wait --fn "Array.from(document.querySelectorAll('input')).some((input) => input.getAttribute('aria-label') === 'Name' || input.labels?.[0]?.textContent?.trim().startsWith('Name'))" --timeout 120000
assert_no_modal_dialog "Board creation form"
ab snapshot -i
ab find role textbox fill "$BOARD_NAME" --name "Name" --exact
ab wait --text "$BOARD_NAME is available"
ab snapshot -i
assert_eval "(() => { const button = Array.from(document.querySelectorAll('button')).find((item) => item.textContent?.trim() === 'Create'); if (!button || button.disabled) throw new Error('Create board button is not enabled'); return true; })()"
capture "$SCREENSHOT_DIR/02-board-inline-create.png"
BOARD_CREATE_REQUESTED=1
BOARD_CLEANUP_PENDING=1
assert_eval "(() => { const button = Array.from(document.querySelectorAll('button')).find((item) => item.textContent?.trim() === 'Create'); if (!button || button.disabled) throw new Error('Create board button is not enabled'); button.click(); return true; })()"
ab wait --url "**/boards/$BOARD_NAME*" --timeout 120000
BOARD_CREATED=1
ab snapshot -i
navigate "/manage/boards"
ab wait --text "$BOARD_NAME"
retry_action_until \
"(() => { const card = Array.from(document.querySelectorAll('main .mantine-Card-root')).find((item) => item.querySelector('a[href=\"/boards/$BOARD_NAME\"]')); const settings = card?.querySelector('button[aria-label=\"Settings\"]'); settings?.scrollIntoView({ block: 'center' }); if (settings?.getAttribute('aria-expanded') !== 'true') settings?.click(); })()" \
"Array.from(document.querySelectorAll('[role=\"menuitem\"]')).some((element) => element.offsetParent !== null && element.textContent?.trim() === 'Delete')" \
"Created board actions did not open"
ab snapshot -i
retry_action_until \
"Array.from(document.querySelectorAll('[role=\"menuitem\"]')).find((element) => element.offsetParent !== null && element.textContent?.trim() === 'Delete')?.click()" \
"Array.from(document.querySelectorAll('[role=\"menuitem\"]')).some((element) => element.offsetParent !== null && element.textContent?.trim() === 'Confirm')" \
"Board Delete action did not enter confirmation state"
ab snapshot -i
retry_action_until \
"Array.from(document.querySelectorAll('[role=\"menuitem\"]')).find((element) => element.offsetParent !== null && element.textContent?.trim() === 'Confirm')?.click()" \
"!document.body.innerText.includes('$BOARD_NAME')" \
"Created board still exists after cleanup"
ab snapshot -i
assert_eval "(() => { if (document.body.innerText.includes('$BOARD_NAME')) throw new Error('Created board still exists after cleanup'); return true; })()"
BOARD_CLEANUP_PENDING=0
BOARD_CREATED=0
echo "[3/5] Calendar inline removal and reload restore"
navigate "/"
ab wait '[data-kind="calendar"]' --timeout 120000
if [[ "$(ab get count 'button[aria-label="Edit board"]')" == "1" ]]; then
retry_action_until \
"document.querySelector('button[aria-label=\"Edit board\"]')?.click()" \
"document.querySelector('button[aria-label=\"Settings for Calendar\"]') !== null" \
"Board did not enter edit mode"
ab snapshot -i
fi
ab hover '[data-kind="calendar"]'
ab wait --fn "(() => { const button = document.querySelector('button[aria-label=\"Settings for Calendar\"]'); return button !== null && button.offsetParent !== null; })()" --timeout 120000
ab snapshot -i
retry_action_until \
"document.querySelector('button[aria-label=\"Settings for Calendar\"]')?.click()" \
"Array.from(document.querySelectorAll('[role=\"menuitem\"]')).some((element) => element.offsetParent !== null && element.textContent?.trim() === 'Remove item')" \
"Calendar actions did not open"
ab snapshot -i
retry_action_until \
"Array.from(document.querySelectorAll('[role=\"menuitem\"]')).find((element) => element.offsetParent !== null && element.textContent?.trim() === 'Remove item')?.click()" \
"Array.from(document.querySelectorAll('[role=\"menuitem\"]')).some((element) => element.offsetParent !== null && element.textContent?.trim() === 'Are you sure?')" \
"Calendar removal did not enter confirmation state"
ab snapshot -i
assert_no_dialog "Calendar removal confirmation"
capture "$SCREENSHOT_DIR/03-calendar-inline-remove-confirmation.png"
retry_action_until \
"Array.from(document.querySelectorAll('[role=\"menuitem\"]')).find((element) => element.offsetParent !== null && element.textContent?.trim() === 'Are you sure?')?.click()" \
"document.querySelector('[data-kind=\"calendar\"]') === null" \
"Calendar item was not removed"
ab snapshot -i
ab reload
ab wait --load domcontentloaded
ab wait '[data-kind="calendar"]'
ab snapshot -i
assert_count '[data-kind="calendar"]' 1 "Calendar item was not restored by reloading without Save"
echo "[4/5] API token inline creation and exact-row cleanup"
navigate "/manage/tools/api"
retry_action_until \
"Array.from(document.querySelectorAll('[role=\"tab\"]')).find((element) => element.textContent?.trim() === 'Authentication')?.click()" \
"Array.from(document.querySelectorAll('h1, h2, h3')).some((heading) => heading.offsetParent !== null && heading.textContent?.trim() === 'API Keys')" \
"Authentication tab did not open"
ab snapshot -i
capture_api_key_ids API_KEY_IDS_BEFORE || fail "Could not capture the pre-create API key IDs"
API_KEY_IDS_BEFORE_SET=()
for api_key_id in "${API_KEY_IDS_BEFORE[@]}"; do
API_KEY_IDS_BEFORE_SET["$api_key_id"]=1
done
API_KEY_IDS_BEFORE_JSON="$(api_key_ids_to_json API_KEY_IDS_BEFORE)"
API_KEY_CLEANUP_PENDING=1
ab wait --fn "(() => { const button = Array.from(document.querySelectorAll('button')).find((item) => item.textContent?.trim() === 'Create API token'); return button !== undefined && !button.disabled && Object.keys(button).some((key) => key.startsWith('__reactProps$')); })()" --timeout 120000
ab find role button click --name "Create API token" --exact
ab wait --text "API token created" --timeout 120000
ab snapshot -i -s "table"
assert_eval "(() => { const button = Array.from(document.querySelectorAll('button')).find((item) => item.textContent?.trim() === 'Create API token'); if (!button?.disabled) throw new Error('Create API token is not disabled while the new token is visible'); return true; })()"
assert_no_dialog "API token creation result"
# The token stays masked; do not snapshot, reveal, copy, or read the secret alert.
capture "$SCREENSHOT_DIR/04-api-token-created-inline.png"
retry_action_until \
"Array.from(document.querySelectorAll('button')).find((item) => item.textContent?.trim() === 'Close' && item.offsetParent !== null)?.click()" \
"!document.body.innerText.includes('API token created')" \
"API token result did not close"
ab snapshot -i
ab reload
ab wait --load domcontentloaded
retry_action_until \
"Array.from(document.querySelectorAll('[role=\"tab\"]')).find((element) => element.textContent?.trim() === 'Authentication')?.click()" \
"Array.from(document.querySelectorAll('h1, h2, h3')).some((heading) => heading.offsetParent !== null && heading.textContent?.trim() === 'API Keys')" \
"Authentication tab did not reopen after refreshing API keys"
ab wait --fn "(() => { const previous = new Set($API_KEY_IDS_BEFORE_JSON); const created = Array.from(document.querySelectorAll('button[aria-label=\"Delete API token\"]')).map((button) => button.closest('tr')?.querySelector('td')?.textContent?.trim().toLowerCase()).filter(Boolean).filter((id) => !previous.has(id)); return created.length === 1; })()" --timeout 120000
ab snapshot -i
declare -a API_KEY_IDS_AFTER_CREATE=()
capture_api_key_ids API_KEY_IDS_AFTER_CREATE || fail "Could not capture the post-create API key IDs"
for api_key_id in "${API_KEY_IDS_AFTER_CREATE[@]}"; do
if [[ -z "${API_KEY_IDS_BEFORE_SET[$api_key_id]+present}" ]]; then
if [[ -n "$API_KEY_CREATED_ID" ]]; then
fail "More than one API key appeared after creation"
fi
API_KEY_CREATED_ID="$api_key_id"
fi
done
if [[ -z "$API_KEY_CREATED_ID" ]]; then
fail "Could not identify the newly created API token row"
fi
assert_eval "(() => { const id = '$API_KEY_CREATED_ID'; const button = Array.from(document.querySelectorAll('button[aria-label=\"Delete API token\"]')).find((item) => item.closest('tr')?.querySelector('td')?.textContent?.trim().toLowerCase() === id); if (!button) throw new Error('Created API key row was not found'); button.setAttribute('aria-label', 'Delete created API token'); return true; })()"
ab find role button click --name "Delete created API token" --exact
ab wait --fn "document.querySelector('button[aria-label=\"Confirm\"]')?.offsetParent !== null"
ab snapshot -i
assert_no_dialog "API token deletion confirmation"
capture "$SCREENSHOT_DIR/05-api-token-inline-delete-confirmation.png"
ab find role button click --name "Confirm" --exact
ab wait --fn "!Array.from(document.querySelectorAll('tr')).some((item) => item.querySelector('td')?.textContent?.trim().toLowerCase() === '$API_KEY_CREATED_ID')" --timeout 120000
ab snapshot -i
assert_eval "(() => { const previous = new Set($API_KEY_IDS_BEFORE_JSON); const current = Array.from(document.querySelectorAll('button[aria-label=\"Delete API token\"]')).map((button) => button.closest('tr')?.querySelector('td')?.textContent?.trim().toLowerCase()).filter(Boolean); if (current.length !== previous.size || current.some((id) => !previous.has(id))) throw new Error('New API token row still exists after cleanup'); return true; })()"
capture "$SCREENSHOT_DIR/06-api-token-cleaned-up.png"
declare -a API_KEY_IDS_AFTER_CLEANUP=()
capture_api_key_ids API_KEY_IDS_AFTER_CLEANUP || fail "Could not verify API key cleanup"
api_key_ids_match_baseline API_KEY_IDS_AFTER_CLEANUP || fail "API key cleanup did not restore the pre-create IDs"
API_KEY_CLEANUP_PENDING=0
API_KEY_CREATED_ID=""
echo "[5/5] Certificate upload inline expansion"
navigate "/manage/tools/certificates"
retry_action_until \
"Array.from(document.querySelectorAll('button')).find((item) => item.textContent?.trim() === 'Add certificate' && item.getAttribute('aria-expanded') === 'false')?.click()" \
"Array.from(document.querySelectorAll('button')).some((item) => item.textContent?.trim() === 'Add certificate' && item.getAttribute('aria-expanded') === 'true')" \
"Add certificate section did not expand"
ab snapshot -i
assert_no_dialog "Add certificate section"
capture "$SCREENSHOT_DIR/07-certificate-upload-inline.png"
retry_action_until \
"Array.from(document.querySelectorAll('button')).find((item) => item.textContent?.trim() === 'Cancel' && item.offsetParent !== null)?.click()" \
"Array.from(document.querySelectorAll('button')).some((item) => item.textContent?.trim() === 'Add certificate' && item.getAttribute('aria-expanded') === 'false')" \
"Add certificate section did not close"
ab snapshot -i
echo "UX coherence happy paths passed. Screenshots remain in $SCREENSHOT_DIR"
+121
View File
@@ -0,0 +1,121 @@
import { gzipSync } from "node:zlib";
import { readdir, readFile, stat } from "node:fs/promises";
import { basename, join, relative, resolve } from "node:path";
const repositoryRoot = resolve(import.meta.dirname, "..");
const nextRoot = resolve(process.env.CUSTOM_WIDGET_NEXT_ROOT ?? join(repositoryRoot, "apps/nextjs/.next"));
const budgetBytes = Number(process.env.CUSTOM_WIDGET_CHUNK_BUDGET_BYTES ?? 768 * 1024);
if (!Number.isSafeInteger(budgetBytes) || budgetBytes <= 0) {
throw new Error("CUSTOM_WIDGET_CHUNK_BUDGET_BYTES must be a positive integer.");
}
async function collect(directory, predicate) {
const result = [];
for (const entry of await readdir(directory, { withFileTypes: true })) {
const path = join(directory, entry.name);
if (entry.isDirectory()) result.push(...(await collect(path, predicate)));
else if (predicate(entry.name, path)) result.push(path);
}
return result;
}
async function resolveChunk(chunk) {
const normalized = chunk.replace(/^\/_next\//u, "");
const candidates = [join(nextRoot, normalized), join(nextRoot, "static/chunks", basename(normalized))];
for (const candidate of candidates) {
try {
if ((await stat(candidate)).isFile()) return candidate;
} catch {
// Try the next manifest-path representation.
}
}
return null;
}
function isRuntimeOrWorkbenchRoute(manifestPath) {
const normalizedManifestPath = manifestPath.replaceAll("\\", "/");
return (
normalizedManifestPath.includes("/(board)/") ||
normalizedManifestPath.includes("/boards/(content)/(home)/") ||
normalizedManifestPath.includes("/manage/custom-widgets/")
);
}
let serverChunks;
let manifestPaths;
try {
[serverChunks, manifestPaths] = await Promise.all([
collect(join(nextRoot, "server/chunks/ssr"), (name) =>
/^packages_widgets_src_custom-api_component.*\.js$/u.test(name),
),
collect(join(nextRoot, "standalone"), (name) => name === "react-loadable-manifest.json"),
]);
} catch {
throw new Error("Next.js build output is missing. Run the Next.js production build before the bundle check.");
}
const moduleIds = new Set();
for (const serverChunk of serverChunks) {
const source = await readFile(serverChunk, "utf8");
for (const match of source.matchAll(/loadableGenerated:\{modules:\[(\d+)\]\}/gu)) moduleIds.add(match[1]);
}
if (moduleIds.size === 0) {
const hasRuntimeMarker = new Map();
for (const manifestPath of manifestPaths) {
if (!isRuntimeOrWorkbenchRoute(manifestPath)) continue;
const manifest = JSON.parse(await readFile(manifestPath, "utf8"));
for (const [moduleId, entry] of Object.entries(manifest)) {
for (const file of entry.files ?? []) {
if (!file.endsWith(".js")) continue;
let hasMarker = hasRuntimeMarker.get(file);
if (hasMarker === undefined) {
const chunkPath = await resolveChunk(file);
hasMarker = chunkPath ? (await readFile(chunkPath, "utf8")).includes("RUNTIME_RENDER_ERROR") : false;
hasRuntimeMarker.set(file, hasMarker);
}
if (!hasMarker) continue;
moduleIds.add(moduleId);
break;
}
}
}
}
if (moduleIds.size === 0) {
throw new Error("The Custom JSX dynamic module was not found in the Next.js server build.");
}
const routePayloads = [];
for (const manifestPath of manifestPaths) {
if (!isRuntimeOrWorkbenchRoute(manifestPath)) continue;
const manifest = JSON.parse(await readFile(manifestPath, "utf8"));
const files = new Set();
for (const moduleId of moduleIds) {
for (const file of manifest[moduleId]?.files ?? []) if (file.endsWith(".js")) files.add(file);
}
if (files.size === 0) continue;
let rawBytes = 0;
let compressedBytes = 0;
for (const file of files) {
const chunkPath = await resolveChunk(file);
if (!chunkPath) throw new Error(`Custom JSX chunk ${file} referenced by a route manifest is missing.`);
const source = await readFile(chunkPath);
rawBytes += source.byteLength;
compressedBytes += gzipSync(source).byteLength;
}
routePayloads.push({ manifestPath, files: files.size, rawBytes, compressedBytes });
}
if (routePayloads.length === 0) {
throw new Error("No route manifest references the Custom JSX dynamic module.");
}
routePayloads.sort((left, right) => right.compressedBytes - left.compressedBytes);
const largest = routePayloads[0];
if (largest.compressedBytes > budgetBytes) {
throw new Error(
`The largest Custom JSX route payload is ${largest.compressedBytes} gzip bytes (${largest.rawBytes} raw), exceeding the ${budgetBytes} byte budget in ${relative(nextRoot, largest.manifestPath)}.`,
);
}
console.log(
`Custom JSX bundle check passed: ${routePayloads.length} routes, largest payload ${largest.compressedBytes} gzip bytes (${largest.rawBytes} raw across ${largest.files} chunks).`,
);
@@ -0,0 +1,57 @@
// @vitest-environment node
import { spawnSync } from "node:child_process";
import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { dirname, join } from "node:path";
import { describe, expect, test } from "vitest";
describe("Custom Widget bundle budget", () => {
test.each(["not-a-number", "0", "-1", "1.5", String(Number.MAX_SAFE_INTEGER + 1)])(
"rejects an invalid byte budget (%s)",
(budget) => {
const result = spawnSync(process.execPath, ["scripts/check-custom-widget-bundle.mjs"], {
cwd: process.cwd(),
encoding: "utf8",
env: { ...process.env, CUSTOM_WIDGET_CHUNK_BUDGET_BYTES: budget },
});
expect(result.status).not.toBe(0);
expect(result.stderr).toContain("CUSTOM_WIDGET_CHUNK_BUDGET_BYTES must be a positive integer");
expect(result.stderr).not.toContain("Next.js build output is missing");
},
);
test("finds the dynamic module from its runtime chunk when server metadata is absent", async () => {
const nextRoot = await mkdtemp(join(tmpdir(), "homarr-custom-widget-bundle-"));
const serverChunk = join(nextRoot, "server/chunks/ssr/packages_widgets_src_custom-api_component_test.js");
const manifestPath = join(
nextRoot,
"standalone/apps/nextjs/.next/server/app/[locale]/(home)/(board)/page/react-loadable-manifest.json",
);
const runtimeChunk = join(nextRoot, "static/chunks/custom-widget-runtime.js");
try {
await Promise.all([
mkdir(dirname(serverChunk), { recursive: true }),
mkdir(dirname(manifestPath), { recursive: true }),
mkdir(dirname(runtimeChunk), { recursive: true }),
]);
await Promise.all([
writeFile(serverChunk, "const generatedWithoutLoadableMetadata = true;"),
writeFile(runtimeChunk, 'const runtimeError = "RUNTIME_RENDER_ERROR";'),
writeFile(manifestPath, JSON.stringify({ 42: { id: 42, files: ["static/chunks/custom-widget-runtime.js"] } })),
]);
const result = spawnSync(process.execPath, ["scripts/check-custom-widget-bundle.mjs"], {
cwd: process.cwd(),
encoding: "utf8",
env: { ...process.env, CUSTOM_WIDGET_NEXT_ROOT: nextRoot },
});
expect(result.status).toBe(0);
expect(result.stdout).toContain("Custom JSX bundle check passed");
} finally {
await rm(nextRoot, { force: true, recursive: true });
}
});
});
+43
View File
@@ -0,0 +1,43 @@
import assert from "node:assert/strict";
import { readFile, writeFile } from "node:fs/promises";
import { openApiDocument } from "../packages/api/src/open-api";
const baseUrl = "http://localhost:3000";
const schemaPath = "apps/docs/public/api/open-api-schema.json";
const writeMode = process.argv.slice(2).includes("--write");
const serialized = `${JSON.stringify(openApiDocument(baseUrl), null, 2)}\n`;
const generated = JSON.parse(serialized) as unknown;
function assertOpenApiDocument(value: unknown): asserts value is { openapi: string; paths: Record<string, unknown> } {
assert.ok(typeof value === "object" && value !== null && !Array.isArray(value), "OpenAPI document must be an object");
assert.equal(typeof Reflect.get(value, "openapi"), "string", "OpenAPI document must declare its version");
const paths = Reflect.get(value, "paths");
assert.ok(
typeof paths === "object" && paths !== null && !Array.isArray(paths),
"OpenAPI document must contain paths",
);
}
assertOpenApiDocument(generated);
if (writeMode) {
await writeFile(schemaPath, serialized, "utf8");
console.log(`OpenAPI schema updated (${Object.keys(generated.paths).length} paths)`);
}
const checkedIn = JSON.parse(await readFile(schemaPath, "utf8")) as unknown;
assertOpenApiDocument(checkedIn);
try {
assert.deepStrictEqual(checkedIn, generated);
} catch {
console.error(
"The checked-in OpenAPI schema is stale. Regenerate it from openApiDocument and update apps/docs/public/api/open-api-schema.json.",
);
process.exit(1);
}
// The API import graph owns background handles that are irrelevant to this one-shot script.
process.exit(0);
+8
View File
@@ -46,6 +46,14 @@ if [[ "$mode" == "cleanup" ]]; then
"repository": { "full_name": "homarr-labs/homarr" }
}
EOF
elif [[ "$mode" == "docker" ]]; then
event_name="workflow_dispatch"
cat >"$event_file" <<EOF
{
"inputs": { "operation": "validate", "ref": "$head_sha" },
"repository": { "full_name": "homarr-labs/homarr" }
}
EOF
else
event_name="pull_request"
safe_ref=$(json_escape "$head_ref")
+47 -21
View File
@@ -1,5 +1,45 @@
#!/usr/bin/env bash
MIGRATION_PID=""
NEXTJS_PID=""
NGINX_PID=""
REDIS_PID=""
SHUTTING_DOWN=false
stop_process() {
if [ -n "$1" ]; then
kill -TERM "$1" 2>/dev/null
wait "$1" 2>/dev/null
fi
}
terminate() {
trap '' TERM INT
SHUTTING_DOWN=true
echo "Shutting down..."
stop_process "$MIGRATION_PID"
stop_process "$NEXTJS_PID"
stop_process "$NGINX_PID"
stop_process "$REDIS_PID"
echo "Shutdown complete."
exit 0
}
# Install before migrations: a foreground child defers the shell's signal trap.
trap terminate TERM INT
# Check before creating SQLite files or invoking a removed migration bundle.
reject_mysql() {
echo "ERROR: MySQL is no longer supported in v2. Use the MySQL-to-SQLite converter before starting Homarr."
exit 1
}
case "$DB_DIALECT:$DB_DRIVER" in
mysql:*|mariadb:*|*:mysql2|*:mysql|*:mariadb) reject_mysql ;;
esac
case "$DB_URL" in
[mM][yY][sS][qQ][lL]:*|[mM][aA][rR][iI][aA][dD][bB]:*) reject_mysql ;;
esac
# Create sub directories in volume
mkdir -p /appdata/db
mkdir -p /appdata/redis
@@ -10,8 +50,12 @@ if [ "$DB_MIGRATIONS_DISABLED" = "true" ]; then
echo "DB migrations are disabled, skipping"
else
echo "Running DB migrations"
DISABLE_REDIS_LOGS=true node ./db/migrations/$DB_DIALECT/migrate.cjs ./db/migrations/$DB_DIALECT
if [ $? -ne 0 ]; then
DISABLE_REDIS_LOGS=true node ./db/migrations/$DB_DIALECT/migrate.cjs ./db/migrations/$DB_DIALECT &
MIGRATION_PID=$!
wait "$MIGRATION_PID"
MIGRATION_EXIT_CODE=$?
MIGRATION_PID=""
if [ "$MIGRATION_EXIT_CODE" -ne 0 ]; then
echo "ERROR: DB migrations failed, aborting startup"
exit 1
fi
@@ -46,28 +90,10 @@ else
REDIS_PID=$!
fi
SHUTTING_DOWN=false
terminate() {
SHUTTING_DOWN=true
echo "Shutting down..."
kill -TERM $NEXTJS_PID 2>/dev/null
wait $NEXTJS_PID 2>/dev/null
kill -TERM $NGINX_PID 2>/dev/null
wait $NGINX_PID 2>/dev/null
if [ -n "$REDIS_PID" ]; then
kill -TERM $REDIS_PID 2>/dev/null
wait $REDIS_PID 2>/dev/null
fi
echo "Shutdown complete."
exit 0
}
trap terminate TERM INT
# Next.js standalone uses HOSTNAME as its bind address. Docker's generated
# hostname can be unresolvable, so bind explicitly while nginx uses loopback.
export HOSTNAME=0.0.0.0
export NODE_OPTIONS="--max-semi-space-size=4 ${NODE_OPTIONS:-}"
node apps/nextjs/server.js &
NEXTJS_PID=$!
+26 -9
View File
@@ -1,5 +1,9 @@
import { readFile, writeFile } from "fs/promises";
import { integrationDefs } from "../packages/definitions/src/integration";
import {
getIntegrationDocumentationUrl,
integrationDefs,
integrationKinds,
} from "../packages/definitions/src/integration";
const FILE = "docs/README.md";
const MAX_COLUMNS_PER_ROW = 7;
@@ -21,19 +25,29 @@ async function updateIntegrationList() {
}
// Generate the new integration list
const integrations = Object.values(integrationDefs)
.filter((def) => def.name !== "Mock")
.sort((a, b) => a.name.localeCompare(b.name));
const integrations = integrationKinds
.filter((kind) => kind !== "mock")
.map((kind) => ({
...integrationDefs[kind],
documentationUrl: getIntegrationDocumentationUrl(kind),
}))
.toSorted((a, b) => a.name.localeCompare(b.name));
const tableRows: string[] = [];
let currentRow: string[] = [];
integrations.forEach((integration) => {
currentRow.push(`<td align="center">
<a href="${integration.documentationUrl}" target="_blank" rel="noreferrer noopener">
const cellBody = `
<img src="${integration.iconUrl}" alt="${integration.name}" width="90" height="90" />
<br/>
<br/>
<p align="center">${integration.name.replaceAll(" ", "<br/>")}</p>
</a>
`;
let cellContent = `<div>${cellBody}</div>`;
if (integration.documentationUrl !== null) {
cellContent = `<a href="${integration.documentationUrl}" target="_blank" rel="noreferrer noopener">${cellBody}</a>`;
}
currentRow.push(`<td align="center">
${cellContent}
</td>`);
if (currentRow.length === MAX_COLUMNS_PER_ROW) {
@@ -67,4 +81,7 @@ ${endMarker}`;
await writeFile(FILE, newContent, "utf8");
}
updateIntegrationList().catch(console.error);
updateIntegrationList().catch((error: unknown) => {
console.error(error);
process.exitCode = 1;
});