diff --git a/.github/workflows/claude.yml b/.github/workflows/claude.yml deleted file mode 100644 index cf119f44e..000000000 --- a/.github/workflows/claude.yml +++ /dev/null @@ -1,64 +0,0 @@ -name: Claude Code - -on: - issue_comment: - types: [created] - pull_request_review_comment: - types: [created] - issues: - types: [opened, assigned] - pull_request_review: - types: [submitted] - -jobs: - claude: - if: | - (github.event_name == 'issue_comment' && contains(github.event.comment.body, '@claude')) || - (github.event_name == 'pull_request_review_comment' && contains(github.event.comment.body, '@claude')) || - (github.event_name == 'pull_request_review' && contains(github.event.review.body, '@claude')) || - (github.event_name == 'issues' && (contains(github.event.issue.body, '@claude') || contains(github.event.issue.title, '@claude'))) - runs-on: ubuntu-latest - permissions: - contents: read - pull-requests: read - issues: read - id-token: write - actions: read # Required for Claude to read CI results on PRs - steps: - - name: Checkout repository - uses: actions/checkout@v5 - with: - fetch-depth: 1 - - - name: Run Claude Code - id: claude - uses: anthropics/claude-code-action@beta - with: - claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }} - - # This is an optional setting that allows Claude to read CI results on PRs - additional_permissions: | - actions: read - - # Optional: Specify model (defaults to Claude Sonnet 4, uncomment for Claude Opus 4) - # model: "claude-opus-4-20250514" - - # Optional: Customize the trigger phrase (default: @claude) - # trigger_phrase: "/claude" - - # Optional: Trigger when specific user is assigned to an issue - # assignee_trigger: "claude-bot" - - # Optional: Allow Claude to run specific commands - # allowed_tools: "Bash(npm install),Bash(npm run build),Bash(npm run test:*),Bash(npm run lint:*)" - - # Optional: Add custom instructions for Claude to customize its behavior for your project - # custom_instructions: | - # Follow our coding standards - # Ensure all new code has tests - # Use TypeScript for new files - - # Optional: Custom environment variables for Claude - # claude_env: | - # NODE_ENV: test - diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index 7fe34df16..003506403 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -310,9 +310,6 @@ jobs: - name: Type Check run: pnpm run type-check - - name: Test - run: pnpm run test:ci - - name: Build run: pnpm run build diff --git a/@tailwind-shared/base-utilities.css b/@tailwind-shared/base-utilities.css index 1aec6db34..5fb843677 100644 --- a/@tailwind-shared/base-utilities.css +++ b/@tailwind-shared/base-utilities.css @@ -1,7 +1,8 @@ @custom-variant dark (&:where(.dark, .dark *)); /* Utility defaults for web components (when we were using shadow DOM) */ -:host { +:host, +.unapi { --tw-divide-y-reverse: 0; --tw-border-style: solid; --tw-font-weight: initial; @@ -61,7 +62,7 @@ } */ -body { +.unapi { --color-alpha: #1c1b1b; --color-beta: #f2f2f2; --color-gamma: #999999; @@ -73,13 +74,14 @@ body { --ring-shadow: 0 0 var(--color-beta); } -button:not(:disabled), -[role='button']:not(:disabled) { +.unapi button:not(:disabled), +.unapi [role='button']:not(:disabled) { cursor: pointer; } /* Font size overrides for SSO button component */ -unraid-sso-button { +.unapi unraid-sso-button, +unraid-sso-button.unapi { --text-xs: 0.75rem; --text-sm: 0.875rem; --text-base: 1rem; @@ -93,4 +95,4 @@ unraid-sso-button { --text-7xl: 4.5rem; --text-8xl: 6rem; --text-9xl: 8rem; -} \ No newline at end of file +} diff --git a/plugin/source/dynamix.unraid.net/usr/local/emhttp/plugins/dynamix.my.servers/include/unraid-api.php b/plugin/source/dynamix.unraid.net/usr/local/emhttp/plugins/dynamix.my.servers/include/unraid-api.php index 4cbda52d1..51df7c4dc 100644 --- a/plugin/source/dynamix.unraid.net/usr/local/emhttp/plugins/dynamix.my.servers/include/unraid-api.php +++ b/plugin/source/dynamix.unraid.net/usr/local/emhttp/plugins/dynamix.my.servers/include/unraid-api.php @@ -69,8 +69,45 @@ switch ($command) { response_complete(200, array('result' => $output), $output); break; case 'restart': + $lockFilePath = '/var/run/unraid-api-restart.lock'; + $lockHandle = @fopen($lockFilePath, 'c'); + if ($lockHandle === false) { + response_complete(500, array('error' => 'Unable to open restart lock file'), 'Unable to open restart lock file'); + } + + // Use a lockfile to avoid concurrently running restart commands + $wouldBlock = null; + error_clear_last(); + $acquiredLock = flock($lockHandle, LOCK_EX | LOCK_NB, $wouldBlock); + if (!$acquiredLock) { + if (!empty($wouldBlock)) { + fclose($lockHandle); + response_complete(200, array('success' => true, 'result' => 'Unraid API restart already in progress'), 'Restart already in progress'); + } + + $lastError = error_get_last(); + $errorMessage = 'Unable to acquire restart lock'; + if (!empty($lastError['message'])) { + $errorMessage .= ': ' . $lastError['message']; + } + + fclose($lockHandle); + response_complete(500, array('error' => $errorMessage), $errorMessage); + } + + $pid = getmypid(); + if ($pid !== false) { + ftruncate($lockHandle, 0); + fwrite($lockHandle, (string)$pid); + fflush($lockHandle); + } + exec('/etc/rc.d/rc.unraid-api restart 2>&1', $output, $retval); $output = implode(PHP_EOL, $output); + + flock($lockHandle, LOCK_UN); + fclose($lockHandle); + response_complete(200, array('success' => ($retval === 0), 'result' => $output, 'error' => ($retval !== 0 ? $output : null)), $output); break; case 'status': @@ -100,4 +137,4 @@ switch ($command) { break; } exit; -?> \ No newline at end of file +?> diff --git a/web/__test__/components/ChangelogModal.test.ts b/web/__test__/components/ChangelogModal.test.ts new file mode 100644 index 000000000..a5367f76c --- /dev/null +++ b/web/__test__/components/ChangelogModal.test.ts @@ -0,0 +1,171 @@ +import { ref } from 'vue'; +import { mount } from '@vue/test-utils'; + +import { DOCS } from '~/helpers/urls'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import ChangelogModal from '~/components/UpdateOs/ChangelogModal.vue'; + +vi.mock('@unraid/ui', () => ({ + BrandButton: { template: '' }, + BrandLoading: { template: '
' }, + cn: (...classes: string[]) => classes.filter(Boolean).join(' '), + ResponsiveModal: { template: '
', props: ['open'] }, + ResponsiveModalFooter: { template: '
' }, + ResponsiveModalHeader: { template: '
' }, + ResponsiveModalTitle: { template: '
' }, +})); + +vi.mock('@heroicons/vue/24/solid', () => ({ + ArrowRightIcon: { template: '' }, + ArrowTopRightOnSquareIcon: { template: '' }, + KeyIcon: { template: '' }, + ServerStackIcon: { template: '' }, +})); + +vi.mock('~/components/UpdateOs/RawChangelogRenderer.vue', () => ({ + default: { template: '
', props: ['changelog', 'version', 'date', 't', 'changelogPretty'] }, +})); + +vi.mock('pinia', async () => { + const actual = await vi.importActual('pinia'); + + const isActualStore = (candidate: unknown): candidate is Parameters[0] => + Boolean(candidate && typeof candidate === 'object' && '$id' in candidate); + + const isRefLike = (input: unknown): input is { value: unknown } => + Boolean(input && typeof input === 'object' && 'value' in input); + + return { + ...actual, + storeToRefs: (store: unknown) => { + if (isActualStore(store)) { + return actual.storeToRefs(store); + } + + if (!store || typeof store !== 'object') { + return {}; + } + + const refs: Record = {}; + for (const [key, value] of Object.entries(store)) { + if (isRefLike(value)) { + refs[key] = value; + } + } + + return refs; + }, + }; +}); + +const mockRenew = vi.fn(); +vi.mock('~/store/purchase', () => ({ + usePurchaseStore: () => ({ + renew: mockRenew, + }), +})); + +const mockAvailableWithRenewal = ref(false); +const mockReleaseForUpdate = ref(null); +const mockChangelogModalVisible = ref(false); +const mockSetReleaseForUpdate = vi.fn(); +const mockFetchAndConfirmInstall = vi.fn(); +vi.mock('~/store/updateOs', () => ({ + useUpdateOsStore: () => ({ + availableWithRenewal: mockAvailableWithRenewal, + releaseForUpdate: mockReleaseForUpdate, + changelogModalVisible: mockChangelogModalVisible, + setReleaseForUpdate: mockSetReleaseForUpdate, + fetchAndConfirmInstall: mockFetchAndConfirmInstall, + }), +})); + +const mockDarkMode = ref(false); +const mockTheme = ref({ name: 'default' }); +vi.mock('~/store/theme', () => ({ + useThemeStore: () => ({ + darkMode: mockDarkMode, + theme: mockTheme, + }), +})); + +describe('ChangelogModal iframeSrc', () => { + const mountWithChangelog = (changelogPretty: string | null) => + mount(ChangelogModal, { + props: { + t: (key: string) => key, + open: true, + release: { + version: '6.12.0', + changelogPretty: changelogPretty ?? undefined, + changelog: 'Raw changelog markdown', + name: 'Unraid OS 6.12.0', + date: '2024-01-01', + }, + }, + }); + + beforeEach(() => { + mockRenew.mockClear(); + mockAvailableWithRenewal.value = false; + mockReleaseForUpdate.value = null; + mockChangelogModalVisible.value = false; + mockSetReleaseForUpdate.mockClear(); + mockFetchAndConfirmInstall.mockClear(); + mockDarkMode.value = false; + mockTheme.value = { name: 'default' }; + }); + + it('sanitizes absolute docs URLs to embed within DOCS origin', () => { + const entry = `${DOCS.origin}/go/release-notes/?foo=bar#section`; + const wrapper = mountWithChangelog(entry); + + const iframeSrc = (wrapper.vm as unknown as { iframeSrc: string | null }).iframeSrc; + expect(iframeSrc).toBeTruthy(); + + const iframeUrl = new URL(iframeSrc!); + expect(iframeUrl.origin).toBe(DOCS.origin); + expect(iframeUrl.pathname).toBe('/go/release-notes/'); + expect(iframeUrl.searchParams.get('embed')).toBe('1'); + expect(iframeUrl.searchParams.get('theme')).toBe('light'); + expect(iframeUrl.searchParams.get('entry')).toBe('/go/release-notes/?foo=bar#section'); + }); + + it('builds DOCS-relative URL when provided a path entry', () => { + const wrapper = mountWithChangelog('updates/6.12?tab=notes#overview'); + + const iframeSrc = (wrapper.vm as unknown as { iframeSrc: string | null }).iframeSrc; + expect(iframeSrc).toBeTruthy(); + + const iframeUrl = new URL(iframeSrc!); + expect(iframeUrl.origin).toBe(DOCS.origin); + expect(iframeUrl.pathname).toBe('/updates/6.12'); + expect(iframeUrl.searchParams.get('entry')).toBe('/updates/6.12?tab=notes#overview'); + }); + + it('applies dark theme when current UI theme requires it', () => { + mockTheme.value = { name: 'azure' }; + const wrapper = mountWithChangelog(`${DOCS.origin}/release/6.12`); + + const iframeSrc = (wrapper.vm as unknown as { iframeSrc: string | null }).iframeSrc; + expect(iframeSrc).toBeTruthy(); + + const iframeUrl = new URL(iframeSrc!); + expect(iframeUrl.searchParams.get('theme')).toBe('dark'); + }); + + it('rejects non-docs origins and returns null', () => { + const wrapper = mountWithChangelog('https://example.com/bad'); + + const iframeSrc = (wrapper.vm as unknown as { iframeSrc: string | null }).iframeSrc; + expect(iframeSrc).toBeNull(); + }); + + it('rejects non-http(s) protocols', () => { + const wrapper = mountWithChangelog('javascript:alert(1)'); + + const iframeSrc = (wrapper.vm as unknown as { iframeSrc: string | null }).iframeSrc; + expect(iframeSrc).toBeNull(); + }); +}); diff --git a/web/__test__/components/CheckUpdateResponseModal.test.ts b/web/__test__/components/CheckUpdateResponseModal.test.ts new file mode 100644 index 000000000..a6a074106 --- /dev/null +++ b/web/__test__/components/CheckUpdateResponseModal.test.ts @@ -0,0 +1,271 @@ +import { nextTick, ref } from 'vue'; +import { mount } from '@vue/test-utils'; + +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import type { ComposerTranslation } from 'vue-i18n'; + +import CheckUpdateResponseModal from '~/components/UpdateOs/CheckUpdateResponseModal.vue'; + +const translate: ComposerTranslation = ((key: string, params?: unknown) => { + if (Array.isArray(params) && params.length > 0) { + return params.reduce( + (result, value, index) => result.replace(`{${index}}`, String(value)), + key + ); + } + + if (params && typeof params === 'object') { + return Object.entries(params as Record).reduce( + (result, [placeholder, value]) => result.replace(`{${placeholder}}`, String(value)), + key + ); + } + + if (typeof params === 'number') { + return key.replace('{0}', String(params)); + } + + return key; +}) as ComposerTranslation; + +vi.mock('@unraid/ui', () => ({ + BrandButton: { + name: 'BrandButton', + props: { + text: { + type: String, + default: undefined, + }, + }, + emits: ['click'], + template: '', + }, + BrandLoading: { template: '
' }, + Button: { template: '' }, + cn: (...classes: string[]) => classes.filter(Boolean).join(' '), + DialogDescription: { template: '
' }, + Label: { template: '' }, + ResponsiveModal: { + name: 'ResponsiveModal', + props: ['open', 'dialogClass', 'sheetClass', 'showCloseButton'], + template: '
', + }, + ResponsiveModalFooter: { template: '' }, + ResponsiveModalHeader: { template: '
' }, + ResponsiveModalTitle: { template: '
' }, + Switch: { name: 'Switch', props: ['modelValue'], template: '
' }, + Tooltip: { template: '
' }, + TooltipTrigger: { template: '
' }, + TooltipContent: { template: '
' }, + TooltipProvider: { template: '
' }, +})); + +vi.mock('@heroicons/vue/24/solid', () => ({ + ArrowTopRightOnSquareIcon: { template: '' }, + CheckCircleIcon: { template: '' }, + CogIcon: { template: '' }, + EyeIcon: { template: '' }, + IdentificationIcon: { template: '' }, + KeyIcon: { template: '' }, +})); + +vi.mock('@heroicons/vue/24/outline', () => ({ + ArrowDownTrayIcon: { template: '' }, +})); + +vi.mock('~/components/UpdateOs/IgnoredRelease.vue', () => ({ + default: { template: '
', props: ['label'] }, +})); + +vi.mock('~/composables/dateTime', () => ({ + default: () => ({ + outputDateTimeFormatted: ref('2024-01-01'), + outputDateTimeReadableDiff: ref('today'), + }), +})); + +vi.mock('pinia', async () => { + const actual = await vi.importActual('pinia'); + + const isActualStore = (candidate: unknown): candidate is Parameters[0] => + Boolean(candidate && typeof candidate === 'object' && '$id' in candidate); + + const isRefLike = (input: unknown): input is { value: unknown } => + Boolean(input && typeof input === 'object' && 'value' in input); + + return { + ...actual, + storeToRefs: (store: unknown) => { + if (isActualStore(store)) { + return actual.storeToRefs(store); + } + + if (!store || typeof store !== 'object') { + return {}; + } + + const refs: Record = {}; + for (const [key, value] of Object.entries(store)) { + if (isRefLike(value)) { + refs[key] = value; + } + } + + return refs; + }, + }; +}); + +const mockAccountUpdateOs = vi.fn(); +vi.mock('~/store/account', () => ({ + useAccountStore: () => ({ + updateOs: mockAccountUpdateOs, + }), +})); + +const mockRenew = vi.fn(); +vi.mock('~/store/purchase', () => ({ + usePurchaseStore: () => ({ + renew: mockRenew, + }), +})); + +const mockSetReleaseForUpdate = vi.fn(); +const mockSetModalOpen = vi.fn(); +const mockFetchAndConfirmInstall = vi.fn(); + +const available = ref(null); +const availableWithRenewal = ref(null); +const availableReleaseDate = ref(null); +const availableRequiresAuth = ref(false); +const checkForUpdatesLoading = ref(false); + +vi.mock('~/store/updateOs', () => ({ + useUpdateOsStore: () => ({ + available, + availableWithRenewal, + availableReleaseDate, + availableRequiresAuth, + checkForUpdatesLoading, + setReleaseForUpdate: mockSetReleaseForUpdate, + setModalOpen: mockSetModalOpen, + fetchAndConfirmInstall: mockFetchAndConfirmInstall, + }), +})); + +const regExp = ref(null); +const regUpdatesExpired = ref(false); +const dateTimeFormat = ref('YYYY-MM-DD'); +const osVersion = ref(null); +const updateOsIgnoredReleases = ref([]); +const updateOsNotificationsEnabled = ref(true); +const updateOsResponse = ref<{ changelog?: string | null } | null>(null); + +const mockUpdateOsIgnoreRelease = vi.fn(); + +vi.mock('~/store/server', () => ({ + useServerStore: () => ({ + regExp, + regUpdatesExpired, + dateTimeFormat, + osVersion, + updateOsIgnoredReleases, + updateOsNotificationsEnabled, + updateOsResponse, + updateOsIgnoreRelease: mockUpdateOsIgnoreRelease, + }), +})); + +const mountModal = () => + mount(CheckUpdateResponseModal, { + props: { + open: true, + t: translate, + }, + }); + +describe('CheckUpdateResponseModal', () => { + beforeEach(() => { + available.value = null; + availableWithRenewal.value = null; + availableReleaseDate.value = null; + availableRequiresAuth.value = false; + checkForUpdatesLoading.value = false; + regExp.value = null; + regUpdatesExpired.value = false; + osVersion.value = null; + updateOsIgnoredReleases.value = []; + updateOsNotificationsEnabled.value = true; + updateOsResponse.value = null; + + mockAccountUpdateOs.mockClear(); + mockRenew.mockClear(); + mockSetModalOpen.mockClear(); + mockSetReleaseForUpdate.mockClear(); + mockFetchAndConfirmInstall.mockClear(); + mockUpdateOsIgnoreRelease.mockClear(); + }); + + it('renders loading state while checking for updates', () => { + checkForUpdatesLoading.value = true; + + const wrapper = mountModal(); + + expect(wrapper.find('.responsive-modal-title').text()).toBe('Checking for OS updates...'); + expect(wrapper.find('.brand-loading').exists()).toBe(true); + expect(wrapper.find('.ui-button').text()).toBe('More Options'); + }); + + it('shows up-to-date messaging when no updates are available', async () => { + osVersion.value = '6.12.3'; + updateOsNotificationsEnabled.value = false; + + const wrapper = mountModal(); + await nextTick(); + + expect(wrapper.find('.responsive-modal-title').text()).toBe('Unraid OS is up-to-date'); + expect(wrapper.text()).toContain('Current Version 6.12.3'); + expect(wrapper.text()).toContain( + 'Go to Settings > Notifications to enable automatic OS update notifications for future releases.' + ); + + expect(wrapper.find('.ui-button').text()).toBe('More Options'); + expect(wrapper.text()).toContain('Enable update notifications'); + }); + + it('displays update actions when a new release is available', async () => { + available.value = '6.13.0'; + osVersion.value = '6.12.3'; + updateOsResponse.value = { changelog: '### New release' }; + + const wrapper = mountModal(); + await nextTick(); + + const actionButtons = wrapper.findAll('.brand-button'); + const viewChangelogButton = actionButtons.find((button) => + button.text().includes('View Changelog to Start Update') + ); + expect(viewChangelogButton).toBeDefined(); + + await viewChangelogButton!.trigger('click'); + expect(mockSetReleaseForUpdate).toHaveBeenCalledWith({ changelog: '### New release' }); + }); + + it('includes renew option when update requires license renewal', async () => { + available.value = '6.14.0'; + availableWithRenewal.value = '6.14.0'; + updateOsResponse.value = { changelog: '### Renewal release' }; + + const wrapper = mountModal(); + await nextTick(); + + const actionButtons = wrapper.findAll('.brand-button'); + const labels = actionButtons.map((button) => button.text()); + expect(labels).toContain('View Changelog'); + expect(labels).toContain('Extend License'); + + await actionButtons.find((btn) => btn.text() === 'Extend License')?.trigger('click'); + expect(mockRenew).toHaveBeenCalled(); + }); +}); diff --git a/web/__test__/components/ColorSwitcher.test.ts b/web/__test__/components/ColorSwitcher.test.ts index bb17a4770..0d010e00c 100644 --- a/web/__test__/components/ColorSwitcher.test.ts +++ b/web/__test__/components/ColorSwitcher.test.ts @@ -2,7 +2,7 @@ * ColorSwitcher Component Test Coverage */ -import { nextTick } from 'vue'; +import { nextTick, ref } from 'vue'; import { setActivePinia } from 'pinia'; import { mount } from '@vue/test-utils'; @@ -15,6 +15,15 @@ import type { MockInstance } from 'vitest'; import ColorSwitcher from '~/components/ColorSwitcher.standalone.vue'; import { useThemeStore } from '~/store/theme'; +vi.mock('@vue/apollo-composable', () => ({ + useQuery: () => ({ + result: ref(null), + loading: ref(false), + onResult: vi.fn(), + onError: vi.fn(), + }), +})); + // Explicitly mock @unraid/ui to ensure we use the actual components vi.mock('@unraid/ui', async (importOriginal) => { const actual = (await importOriginal()) as Record; diff --git a/web/__test__/components/HeaderOsVersion.test.ts b/web/__test__/components/HeaderOsVersion.test.ts index 62537d33b..530a697c4 100644 --- a/web/__test__/components/HeaderOsVersion.test.ts +++ b/web/__test__/components/HeaderOsVersion.test.ts @@ -27,6 +27,8 @@ vi.mock('@vue/apollo-composable', () => ({ useQuery: () => ({ result: { value: {} }, loading: { value: false }, + onResult: vi.fn(), + onError: vi.fn(), }), useLazyQuery: () => ({ result: { value: {} }, diff --git a/web/__test__/components/Registration.test.ts b/web/__test__/components/Registration.test.ts index 4ab7c7b66..5d9d47b2f 100644 --- a/web/__test__/components/Registration.test.ts +++ b/web/__test__/components/Registration.test.ts @@ -30,6 +30,8 @@ vi.mock('@vue/apollo-composable', () => ({ useQuery: () => ({ result: { value: {} }, loading: { value: false }, + onResult: vi.fn(), + onError: vi.fn(), }), useLazyQuery: () => ({ result: { value: {} }, diff --git a/web/__test__/store/theme.test.ts b/web/__test__/store/theme.test.ts index abc7c7d1f..7e15402fa 100644 --- a/web/__test__/store/theme.test.ts +++ b/web/__test__/store/theme.test.ts @@ -2,7 +2,7 @@ * Theme store test coverage */ -import { nextTick } from 'vue'; +import { nextTick, ref } from 'vue'; import { createPinia, setActivePinia } from 'pinia'; import { defaultColors } from '~/themes/default'; @@ -11,6 +11,15 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { useThemeStore } from '~/store/theme'; +vi.mock('@vue/apollo-composable', () => ({ + useQuery: () => ({ + result: ref(null), + loading: ref(false), + onResult: vi.fn(), + onError: vi.fn(), + }), +})); + vi.mock('hex-to-rgba', () => ({ default: vi.fn((hex, opacity) => `rgba(mock-${hex}-${opacity})`), })); diff --git a/web/__test__/store/unraidApi.test.ts b/web/__test__/store/unraidApi.test.ts index 19a711cf2..b2cbd6e45 100644 --- a/web/__test__/store/unraidApi.test.ts +++ b/web/__test__/store/unraidApi.test.ts @@ -126,12 +126,18 @@ describe('UnraidApi Store', () => { store.unraidApiStatus = 'offline'; await nextTick(); + expect(mockErrorsStore.removeErrorByRef).toHaveBeenCalledWith('unraidApiOffline'); expect(mockErrorsStore.setError).toHaveBeenCalledWith({ heading: 'Warning: API is offline!', message: 'The Unraid API is currently offline.', ref: 'unraidApiOffline', level: 'warning', type: 'unraidApiState', + actions: [ + expect.objectContaining({ + text: 'Restart unraid-api', + }), + ], }); }); @@ -211,6 +217,28 @@ describe('UnraidApi Store', () => { expect(store.unraidApiStatus).toBe('restarting'); }); + it('should reuse existing restart promise when restart is already running', async () => { + const { WebguiUnraidApiCommand } = await import('~/composables/services/webgui'); + const mockWebguiCommand = vi.mocked(WebguiUnraidApiCommand); + + let resolveCommand: (() => void) | undefined; + const commandPromise = new Promise((resolve) => { + resolveCommand = resolve; + }); + + mockWebguiCommand.mockReturnValueOnce(commandPromise); + + store.unraidApiStatus = 'online'; + + const firstCallPromise = store.restartUnraidApiClient(); + const secondCallPromise = store.restartUnraidApiClient(); + + expect(mockWebguiCommand).toHaveBeenCalledTimes(1); + + resolveCommand?.(); + await Promise.all([firstCallPromise, secondCallPromise]); + }); + it('should handle error during restart', async () => { const { WebguiUnraidApiCommand } = await import('~/composables/services/webgui'); const mockWebguiCommand = vi.mocked(WebguiUnraidApiCommand); diff --git a/web/postcss/scopeTailwindToUnapi.ts b/web/postcss/scopeTailwindToUnapi.ts new file mode 100644 index 000000000..d6f75695e --- /dev/null +++ b/web/postcss/scopeTailwindToUnapi.ts @@ -0,0 +1,165 @@ +interface Container { + type: string; + parent?: Container; +} + +interface Rule extends Container { + selector?: string; + selectors?: string[]; +} + +interface AtRule extends Container { + name: string; + params: string; +} + +type PostcssPlugin = { + postcssPlugin: string; + Rule?(rule: Rule): void; +}; + +type PluginCreator = { + (opts?: T): PostcssPlugin; + postcss?: boolean; +}; + +export interface ScopeOptions { + scope?: string; + layers?: string[]; + includeRoot?: boolean; +} + +const DEFAULT_SCOPE = '.unapi'; +const DEFAULT_LAYERS = ['*']; +const DEFAULT_INCLUDE_ROOT = true; + +const KEYFRAME_AT_RULES = new Set(['keyframes']); +const NON_SCOPED_AT_RULES = new Set(['font-face', 'page']); +const MERGE_WITH_SCOPE_PATTERNS: RegExp[] = [/^\.theme-/, /^\.has-custom-/, /^\.dark\b/]; + +function shouldScopeRule(rule: Rule, targetLayers: Set, includeRootRules: boolean): boolean { + const hasSelectorString = typeof rule.selector === 'string' && rule.selector.length > 0; + const hasSelectorArray = Array.isArray(rule.selectors) && rule.selectors.length > 0; + + // Skip rules without selectors (e.g. @font-face) or nested keyframe steps + if (!hasSelectorString && !hasSelectorArray) { + return false; + } + + const directParent = rule.parent; + if (directParent?.type === 'atrule') { + const parentAtRule = directParent as AtRule; + const parentAtRuleName = parentAtRule.name.toLowerCase(); + if (KEYFRAME_AT_RULES.has(parentAtRuleName) || parentAtRuleName.endsWith('keyframes')) { + return false; + } + if (NON_SCOPED_AT_RULES.has(parentAtRuleName)) { + return false; + } + } + + const includeAllLayers = targetLayers.has('*'); + + // Traverse ancestors to find the enclosing @layer declaration + let current: Container | undefined = rule.parent ?? undefined; + + while (current) { + if (current.type === 'atrule') { + const currentAtRule = current as AtRule; + if (currentAtRule.name === 'layer') { + const layerNames = currentAtRule.params + .split(',') + .map((name: string) => name.trim()) + .filter(Boolean); + if (includeAllLayers) { + return true; + } + return layerNames.some((name) => targetLayers.has(name)); + } + } + current = current.parent ?? undefined; + } + + // If the rule is not inside any @layer, treat it as root-level CSS + return includeRootRules; +} + +function hasScope(selector: string, scope: string): boolean { + return selector.includes(scope); +} + +function prefixSelector(selector: string, scope: string): string { + const trimmed = selector.trim(); + + if (!trimmed) { + return selector; + } + + if (hasScope(trimmed, scope)) { + return trimmed; + } + + // Do not prefix :host selectors – they are only valid at the top level + if (trimmed.startsWith(':host')) { + return trimmed; + } + + if (trimmed === ':root') { + return scope; + } + + if (trimmed.startsWith(':root')) { + return `${scope}${trimmed.slice(':root'.length)}`; + } + + const firstToken = trimmed.split(/[\s>+~]/, 1)[0] ?? ''; + const shouldMergeWithScope = + !firstToken.includes('\\:') && MERGE_WITH_SCOPE_PATTERNS.some((pattern) => pattern.test(firstToken)); + + if (shouldMergeWithScope) { + return `${scope}${trimmed}`; + } + + return `${scope} ${trimmed}`; +} + +export const scopeTailwindToUnapi: PluginCreator = (options: ScopeOptions = {}) => { + const scope = options.scope ?? DEFAULT_SCOPE; + const layers = options.layers ?? DEFAULT_LAYERS; + const includeRootRules = options.includeRoot ?? DEFAULT_INCLUDE_ROOT; + const targetLayers = new Set(layers); + + return { + postcssPlugin: 'scope-tailwind-to-unapi', + Rule(rule: Rule) { + if (!shouldScopeRule(rule, targetLayers, includeRootRules)) { + return; + } + + const hasSelectorArray = Array.isArray(rule.selectors); + let selectors: string[] = []; + + if (hasSelectorArray && rule.selectors) { + selectors = rule.selectors; + } else if (rule.selector) { + selectors = [rule.selector]; + } + + if (!selectors.length) { + return; + } + + const scopedSelectors = selectors.map((selector: string) => prefixSelector(selector, scope)); + + if (hasSelectorArray) { + rule.selectors = scopedSelectors; + } else { + rule.selector = scopedSelectors.join(', '); + } + }, + }; +}; + +scopeTailwindToUnapi.postcss = true; + +export default scopeTailwindToUnapi; diff --git a/web/src/__tests__/scopeTailwindToUnapi.spec.ts b/web/src/__tests__/scopeTailwindToUnapi.spec.ts new file mode 100644 index 000000000..b7d16a398 --- /dev/null +++ b/web/src/__tests__/scopeTailwindToUnapi.spec.ts @@ -0,0 +1,86 @@ +import { performance } from 'node:perf_hooks'; + +import { describe, expect, it } from 'vitest'; + +import scopeTailwindToUnapi from '../../postcss/scopeTailwindToUnapi'; + +type LayerAtRule = { + type: string; + name: string; + params: string; + parent?: LayerAtRule; +}; + +type MutableRule = { + type: string; + selector?: string; + selectors?: string[]; + parent?: LayerAtRule; +}; + +function createRule(selectors: string[], layer = 'utilities'): MutableRule { + return { + type: 'rule', + selector: selectors.join(', '), + selectors: [...selectors], + parent: { + type: 'atrule', + name: 'layer', + params: layer, + }, + }; +} + +describe('scopeTailwindToUnapi plugin', () => { + it('prefixes simple selectors with .unapi scope', () => { + const plugin = scopeTailwindToUnapi(); + const rule = createRule(['.btn-primary']); + + plugin.Rule?.(rule); + + expect(rule.selectors).toEqual(['.unapi .btn-primary']); + }); + + it('merges variant class selectors into the scope', () => { + const plugin = scopeTailwindToUnapi(); + const rule = createRule(['.dark .btn-secondary']); + + plugin.Rule?.(rule); + + expect(rule.selectors).toEqual(['.unapi.dark .btn-secondary']); + }); + + it('handles rules expressed with selector strings only', () => { + const plugin = scopeTailwindToUnapi(); + const rule: MutableRule = { + type: 'rule', + selector: '.card', + parent: { + type: 'atrule', + name: 'layer', + params: 'components', + }, + }; + + plugin.Rule?.(rule); + + expect(rule.selector).toBe('.unapi .card'); + }); + + it('processes large rule sets within the target budget', () => { + const plugin = scopeTailwindToUnapi(); + const totalRules = 10_000; + + const start = performance.now(); + + for (let index = 0; index < totalRules; index += 1) { + const rule = createRule([`.test-${index}`]); + plugin.Rule?.(rule); + } + + const durationMs = performance.now() - start; + + // Ensure we stay well under 1 second even on slower CI hosts. + expect(durationMs).toBeLessThan(1_000); + }); +}); diff --git a/web/src/components/Logs/SingleLogViewer.vue b/web/src/components/Logs/SingleLogViewer.vue index d6427d5ce..7e4817c63 100644 --- a/web/src/components/Logs/SingleLogViewer.vue +++ b/web/src/components/Logs/SingleLogViewer.vue @@ -325,7 +325,7 @@ defineExpose({ refreshLogContent });