Compare commits

...

10 Commits

Author SHA1 Message Date
Eli Bosley
771014b005 chore: fix timezone on pushed PRs to actually redirect correctly 2025-09-15 12:23:11 -04:00
Eli Bosley
31a255c928 fix: false positive on verify_install script being external shell (#1704)
- Introduced a new test script for shell detection logic in
`verify_install.sh`.
- Updated the `package.json` to include a new test command for shell
detection.
- Enhanced the `verify_install.sh` script to accurately check the
current shell interpreter using `/proc` and fallback methods.

This improves the testing framework and ensures better validation of
shell detection functionality.

<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
## Summary by CodeRabbit

- Bug Fixes
- Installer now detects the actual interpreter more robustly and
strictly requires Bash; non-Bash environments produce clear error
messages and abort installation.
- Tests
- Added automated shell-detection tests to validate behavior across
environments.
- Test suite expanded to run the new shell-detection checks as part of
the standard test command.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->

---------

Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
Co-authored-by: Copilot Autofix powered by AI <62310815+github-advanced-security[bot]@users.noreply.github.com>
2025-09-15 12:17:50 -04:00
Eli Bosley
167857a323 chore: fix dev environment 2025-09-15 11:38:04 -04:00
renovate[bot]
b80988aaab fix(deps): update dependency filenamify to v7 (#1703)
This PR contains the following updates:

| Package | Change | Age | Confidence |
|---|---|---|---|
| [filenamify](https://redirect.github.com/sindresorhus/filenamify) |
[`6.0.0` ->
`7.0.0`](https://renovatebot.com/diffs/npm/filenamify/6.0.0/7.0.0) |
[![age](https://developer.mend.io/api/mc/badges/age/npm/filenamify/7.0.0?slim=true)](https://docs.renovatebot.com/merge-confidence/)
|
[![confidence](https://developer.mend.io/api/mc/badges/confidence/npm/filenamify/6.0.0/7.0.0?slim=true)](https://docs.renovatebot.com/merge-confidence/)
|

---

### Release Notes

<details>
<summary>sindresorhus/filenamify (filenamify)</summary>

###
[`v7.0.0`](https://redirect.github.com/sindresorhus/filenamify/releases/tag/v7.0.0)

[Compare
Source](https://redirect.github.com/sindresorhus/filenamify/compare/v6.0.0...v7.0.0)

##### Breaking

- Require Node.js 20
[`cc39041`](https://redirect.github.com/sindresorhus/filenamify/commit/cc39041)
- Fix Unicode normalization
[`ea8b961`](https://redirect.github.com/sindresorhus/filenamify/commit/ea8b961)
- Previously, it used the incorrect `NFD` Unicode normalization. It now
uses the correct `NFC` normalization. This could be a breaking change if
you stored the filenames and then compare, as they won't match. You
could run a `NFC` normalization on the stored once to fix that.

##### Improvements

- Use grapheme-safe truncation
[`98169ce`](https://redirect.github.com/sindresorhus/filenamify/commit/98169ce)
- Fix trailing spaces and periods handling
[`43eea4d`](https://redirect.github.com/sindresorhus/filenamify/commit/43eea4d)

***

</details>

---

### Configuration

📅 **Schedule**: Branch creation - At any time (no schedule defined),
Automerge - At any time (no schedule defined).

🚦 **Automerge**: Disabled by config. Please merge this manually once you
are satisfied.

♻ **Rebasing**: Whenever PR becomes conflicted, or you tick the
rebase/retry checkbox.

🔕 **Ignore**: Close this PR and you won't be reminded about this update
again.

---

- [ ] <!-- rebase-check -->If you want to rebase/retry this PR, check
this box

---

This PR was generated by [Mend Renovate](https://mend.io/renovate/).
View the [repository job
log](https://developer.mend.io/github/unraid/api).

<!--renovate-debug:eyJjcmVhdGVkSW5WZXIiOiI0MS45Ny4xMCIsInVwZGF0ZWRJblZlciI6IjQxLjk3LjEwIiwidGFyZ2V0QnJhbmNoIjoibWFpbiIsImxhYmVscyI6W119-->

Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-09-15 11:24:31 -04:00
Eli Bosley
fe4a6451f1 chore: update pnpm lock 2025-09-15 11:24:31 -04:00
renovate[bot]
9a86c615da fix(deps): pin dependency conventional-changelog-conventionalcommits to 9.1.0 (#1697)
This PR contains the following updates:

| Package | Type | Update | Change |
|---|---|---|---|
|
[conventional-changelog-conventionalcommits](https://redirect.github.com/conventional-changelog/conventional-changelog/tree/master/packages/conventional-changelog-conventionalcommits#readme)
([source](https://redirect.github.com/conventional-changelog/conventional-changelog/tree/HEAD/packages/conventional-changelog-conventionalcommits))
| dependencies | pin | [`^9.1.0` ->
`9.1.0`](https://renovatebot.com/diffs/npm/conventional-changelog-conventionalcommits/9.1.0/9.1.0)
|

Add the preset `:preserveSemverRanges` to your config if you don't want
to pin your dependencies.

---

### Configuration

📅 **Schedule**: Branch creation - At any time (no schedule defined),
Automerge - At any time (no schedule defined).

🚦 **Automerge**: Disabled by config. Please merge this manually once you
are satisfied.

♻ **Rebasing**: Whenever PR is behind base branch, or you tick the
rebase/retry checkbox.

🔕 **Ignore**: Close this PR and you won't be reminded about this update
again.

---

- [ ] <!-- rebase-check -->If you want to rebase/retry this PR, check
this box

---

This PR was generated by [Mend Renovate](https://mend.io/renovate/).
View the [repository job
log](https://developer.mend.io/github/unraid/api).

<!--renovate-debug:eyJjcmVhdGVkSW5WZXIiOiI0MS45Ny4xMCIsInVwZGF0ZWRJblZlciI6IjQxLjk3LjEwIiwidGFyZ2V0QnJhbmNoIjoibWFpbiIsImxhYmVscyI6W119-->

Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-09-15 11:24:31 -04:00
renovate[bot]
25ff8992a5 chore(deps): update dependency type-fest to v5 (#1701)
This PR contains the following updates:

| Package | Change | Age | Confidence |
|---|---|---|---|
| [type-fest](https://redirect.github.com/sindresorhus/type-fest) |
[`4.41.0` ->
`5.0.0`](https://renovatebot.com/diffs/npm/type-fest/4.41.0/5.0.0) |
[![age](https://developer.mend.io/api/mc/badges/age/npm/type-fest/5.0.0?slim=true)](https://docs.renovatebot.com/merge-confidence/)
|
[![confidence](https://developer.mend.io/api/mc/badges/confidence/npm/type-fest/4.41.0/5.0.0?slim=true)](https://docs.renovatebot.com/merge-confidence/)
|

---

### Release Notes

<details>
<summary>sindresorhus/type-fest (type-fest)</summary>

###
[`v5.0.0`](https://redirect.github.com/sindresorhus/type-fest/releases/tag/v5.0.0)

[Compare
Source](https://redirect.github.com/sindresorhus/type-fest/compare/v4.41.0...v5.0.0)

##### Breaking

- This package is now pure ESM. **Please [read
this](https://gist.github.com/sindresorhus/a39789f98801d908bbc7ff3ecc99d99c).**
- Require TypeScript 5.9
[`b5b0214`](https://redirect.github.com/sindresorhus/type-fest/commit/b5b0214)
- Require Node.js 20
[`cc2b0f2`](https://redirect.github.com/sindresorhus/type-fest/commit/cc2b0f2)
- Reminder: `type-fest` requires `strict: true` in your tsconfig.
- `StringKeyOf`: Rename to
[`KeyAsString`](https://redirect.github.com/sindresorhus/type-fest/blob/main/source/key-as-string.d.ts)
[`e492c9c`](https://redirect.github.com/sindresorhus/type-fest/commit/e492c9c)
-
[`ArrayTail`](https://redirect.github.com/sindresorhus/type-fest/blob/main/source/array-tail.d.ts):
Enable
[`preserveReadonly`](68469722a9/source/array-tail.d.ts (L8-L30))
by default and remove the option
[`b34b1d8`](https://redirect.github.com/sindresorhus/type-fest/commit/b34b1d8)
-
[`CamelCase`](https://redirect.github.com/sindresorhus/type-fest/blob/main/source/camel-case.d.ts)
/
[`CamelCasedProperties`](https://redirect.github.com/sindresorhus/type-fest/blob/main/source/camel-cased-properties.d.ts)
/
[`CamelCasedPropertiesDeep`](https://redirect.github.com/sindresorhus/type-fest/blob/main/source/camel-cased-properties-deep.d.ts)
/
[`PascalCase`](https://redirect.github.com/sindresorhus/type-fest/blob/main/source/pascal-case.d.ts)
/
[`PascalCasedProperties`](https://redirect.github.com/sindresorhus/type-fest/blob/main/source/pascal-cased-properties.d.ts)
/
[`PascalCasedPropertiesDeep`](https://redirect.github.com/sindresorhus/type-fest/blob/main/source/pascal-cased-properties-deep.d.ts):
Disable `preserveConsecutiveUppercase` by default
[`8226c1b`](https://redirect.github.com/sindresorhus/type-fest/commit/8226c1b)
  - This aligns it with the general JavaScript naming convention.
-
[`PartialDeep`](https://redirect.github.com/sindresorhus/type-fest/blob/main/source/partial-deep.d.ts):
Disable `allowUndefinedInNonTupleArrays` by default
[`b3c4524`](https://redirect.github.com/sindresorhus/type-fest/commit/b3c4524)
-
[`Split`](https://redirect.github.com/sindresorhus/type-fest/blob/main/source/split.d.ts):
Enable `strictLiteralChecks` by default
[`544a846`](https://redirect.github.com/sindresorhus/type-fest/commit/544a846)
-
[`Paths`](https://redirect.github.com/sindresorhus/type-fest/blob/main/source/paths.d.ts):
Default `maxRecursionDepth` 5 (was 10)
[`2ab5dec`](https://redirect.github.com/sindresorhus/type-fest/commit/2ab5dec)
-
[`ObservableLike`](https://redirect.github.com/sindresorhus/type-fest/blob/main/source/globals/observable-like.d.ts):
Move to sub-export
[`2a1072e`](https://redirect.github.com/sindresorhus/type-fest/commit/2a1072e)
- Deprecate `If*` types in favor of a single
[`If`](https://redirect.github.com/sindresorhus/type-fest/blob/main/source/if.d.ts)
[`4c2151a`](https://redirect.github.com/sindresorhus/type-fest/commit/4c2151a)

##### New types

-
[`Alphanumeric`](fc14f87e7f/source/characters.d.ts)
— Single alphanumeric character (`A–Z`, `a–z`, `0–9`).
[`484e030`](https://redirect.github.com/sindresorhus/type-fest/commit/484e030)
-
[`AllExtend`](https://redirect.github.com/sindresorhus/type-fest/blob/main/source/all-extend.d.ts)
— Evaluates to `true` if every element of a tuple/array extends `U`.
[`c8c6d55`](https://redirect.github.com/sindresorhus/type-fest/commit/c8c6d55)
-
[`ConditionalSimplify`](https://redirect.github.com/sindresorhus/type-fest/blob/main/source/conditional-simplify.d.ts)
— Simplifies a type’s unions/intersections with opt-in controls.
[`b7a4771`](https://redirect.github.com/sindresorhus/type-fest/commit/b7a4771)
-
[`ConditionalSimplifyDeep`](https://redirect.github.com/sindresorhus/type-fest/blob/main/source/conditional-simplify-deep.d.ts)
— Deep version of `ConditionalSimplify` that recurses into objects.
[`b7a4771`](https://redirect.github.com/sindresorhus/type-fest/commit/b7a4771)
-
[`DigitCharacter`](fc14f87e7f/source/characters.d.ts)
— Single ASCII digit character (`0–9`).
[`484e030`](https://redirect.github.com/sindresorhus/type-fest/commit/484e030)
-
[`ExcludeStrict`](https://redirect.github.com/sindresorhus/type-fest/blob/main/source/exclude-strict.d.ts)
— Non-distributive, stricter variant of `Exclude<T, U>`.
[`e6f62a2`](https://redirect.github.com/sindresorhus/type-fest/commit/e6f62a2)
-
[`ExtendsStrict`](https://redirect.github.com/sindresorhus/type-fest/blob/main/source/extends-strict.d.ts)
— Non-distributive `A extends B` check.
[`d71242a`](https://redirect.github.com/sindresorhus/type-fest/commit/d71242a)
-
[`ExtractStrict`](https://redirect.github.com/sindresorhus/type-fest/blob/main/source/extract-strict.d.ts)
— Non-distributive, stricter variant of `Extract<T, U>`.
[`98d24fa`](https://redirect.github.com/sindresorhus/type-fest/commit/98d24fa)
-
[`IsLowercase`](https://redirect.github.com/sindresorhus/type-fest/blob/main/source/is-lowercase.d.ts)
— Evaluates to `true` if a string literal is all lowercase.
[`afe132c`](https://redirect.github.com/sindresorhus/type-fest/commit/afe132c)
-
[`IsNullable`](https://redirect.github.com/sindresorhus/type-fest/blob/main/source/is-nullable.d.ts)
— Evaluates to `true` if `T` includes `null`.
[`5067e25`](https://redirect.github.com/sindresorhus/type-fest/commit/5067e25)
-
[`IsOptional`](https://redirect.github.com/sindresorhus/type-fest/blob/main/source/is-optional.d.ts)
— Evaluates to `true` if `T` includes `undefined`.
[`5067e25`](https://redirect.github.com/sindresorhus/type-fest/commit/5067e25)
-
[`IsOptionalKeyOf`](https://redirect.github.com/sindresorhus/type-fest/blob/main/source/is-optional-key-of.d.ts)
— Evaluates to `true` if property `K` of `T` is optional.
[`93728b5`](https://redirect.github.com/sindresorhus/type-fest/commit/93728b5)
-
[`IsReadonlyKeyOf`](https://redirect.github.com/sindresorhus/type-fest/blob/main/source/is-readonly-key-of.d.ts)
— Evaluates to `true` if property `K` of `T` is readonly.
[`93728b5`](https://redirect.github.com/sindresorhus/type-fest/commit/93728b5)
-
[`IsRequiredKeyOf`](https://redirect.github.com/sindresorhus/type-fest/blob/main/source/is-required-key-of.d.ts)
— Evaluates to `true` if property `K` of `T` is required.
[`93728b5`](https://redirect.github.com/sindresorhus/type-fest/commit/93728b5)
-
[`IsUnion`](https://redirect.github.com/sindresorhus/type-fest/blob/main/source/is-union.d.ts)
— Evaluates to `true` if `T` is a union type.
[`b3d92ed`](https://redirect.github.com/sindresorhus/type-fest/commit/b3d92ed)
-
[`IsUndefined`](https://redirect.github.com/sindresorhus/type-fest/blob/main/source/is-undefined.d.ts)
— Evaluates to `true` if the type is exactly `undefined`.
[`f7bc576`](https://redirect.github.com/sindresorhus/type-fest/commit/f7bc576)
-
[`IsUppercase`](https://redirect.github.com/sindresorhus/type-fest/blob/main/source/is-uppercase.d.ts)
— Evaluates to `true` if a string literal is all uppercase.
[`afe132c`](https://redirect.github.com/sindresorhus/type-fest/commit/afe132c)
-
[`LowercaseLetter`](fc14f87e7f/source/characters.d.ts)
— Single lowercase Latin letter (`a–z`).
[`484e030`](https://redirect.github.com/sindresorhus/type-fest/commit/484e030)
-
[`RemovePrefix`](https://redirect.github.com/sindresorhus/type-fest/blob/main/source/remove-prefix.d.ts)
— Removes a specified prefix from a string literal.
[`18a1c04`](https://redirect.github.com/sindresorhus/type-fest/commit/18a1c04)
-
[`UppercaseLetter`](fc14f87e7f/source/characters.d.ts)
— Single uppercase Latin letter (`A–Z`).
[`484e030`](https://redirect.github.com/sindresorhus/type-fest/commit/484e030)

##### Improvements

-
[`Jsonify`](https://redirect.github.com/sindresorhus/type-fest/blob/main/source/jsonify.d.ts):
Handle `unknown` as `JsonValue`
[`642bb13`](https://redirect.github.com/sindresorhus/type-fest/commit/642bb13)
-
[`SetRequired`](https://redirect.github.com/sindresorhus/type-fest/blob/main/source/set-required.d.ts)
/
[`SetOptional`](https://redirect.github.com/sindresorhus/type-fest/blob/main/source/set-optional.d.ts)
/
[`SetReadonly`](https://redirect.github.com/sindresorhus/type-fest/blob/main/source/set-readonly.d.ts):
Handle functions with properties
[`a5e45d4`](https://redirect.github.com/sindresorhus/type-fest/commit/a5e45d4)
-
[`Schema`](https://redirect.github.com/sindresorhus/type-fest/blob/main/source/schema.d.ts):
Preserve arrays/remove extraneous unions
[`8a96def`](https://redirect.github.com/sindresorhus/type-fest/commit/8a96def);
drop `undefined` for `recurseIntoArrays`
[`1cb955b`](https://redirect.github.com/sindresorhus/type-fest/commit/1cb955b)
-
[`ReadonlyKeysOf`](https://redirect.github.com/sindresorhus/type-fest/blob/main/source/readonly-keys-of.d.ts)
/
[`WritableKeysOf`](https://redirect.github.com/sindresorhus/type-fest/blob/main/source/writable-keys-of.d.ts):
Add `object` constraint
[`a6efbe0`](https://redirect.github.com/sindresorhus/type-fest/commit/a6efbe0)
-
[`TsConfigJson`](https://redirect.github.com/sindresorhus/type-fest/blob/main/source/tsconfig-json.d.ts):
Add TypeScript 5.9 fields
[`d2bda94`](https://redirect.github.com/sindresorhus/type-fest/commit/d2bda94)

##### Fixes

-
[`Or`](https://redirect.github.com/sindresorhus/type-fest/blob/main/source/or.d.ts):
Fix with `boolean`, `never`, `any`
[`42d6106`](https://redirect.github.com/sindresorhus/type-fest/commit/42d6106)
-
[`And`](https://redirect.github.com/sindresorhus/type-fest/blob/main/source/and.d.ts):
Fix with `boolean`, `never`, `any`
[`b38ac60`](https://redirect.github.com/sindresorhus/type-fest/commit/b38ac60)
-
[`IsStringLiteral`](https://redirect.github.com/sindresorhus/type-fest/blob/main/source/is-literal.d.ts):
Fix uncollapsed unions, and tagged types
[`eb37799`](https://redirect.github.com/sindresorhus/type-fest/commit/eb37799)
/
[`d1b35c7`](https://redirect.github.com/sindresorhus/type-fest/commit/d1b35c7)
-
[`Paths`](https://redirect.github.com/sindresorhus/type-fest/blob/main/source/paths.d.ts):
Fix behavior with index signatures
[`9926e5d`](https://redirect.github.com/sindresorhus/type-fest/commit/9926e5d)
-
[`ConditionalKeys`](https://redirect.github.com/sindresorhus/type-fest/blob/main/source/conditional-keys.d.ts):
Fix behavior with arrays and unions
[`4d7cc50`](https://redirect.github.com/sindresorhus/type-fest/commit/4d7cc50)
-
[`RequiredDeep`](https://redirect.github.com/sindresorhus/type-fest/blob/main/source/required-deep.d.ts):
Fix with `undefined`
[`bfcdbc4`](https://redirect.github.com/sindresorhus/type-fest/commit/bfcdbc4)
-
[`Split`](https://redirect.github.com/sindresorhus/type-fest/blob/main/source/split.d.ts):
Fix template strings ending with interpolation
[`853b881`](https://redirect.github.com/sindresorhus/type-fest/commit/853b881)
-
[`ArrayTail`](https://redirect.github.com/sindresorhus/type-fest/blob/main/source/array-tail.d.ts):
Fix fix non-tuple arrays
[`f3aabd8`](https://redirect.github.com/sindresorhus/type-fest/commit/f3aabd8)
- Fix `UnionMin` and `UnionMax`
[`d52d5e7`](https://redirect.github.com/sindresorhus/type-fest/commit/d52d5e7)

##### Meta

Huge thanks to all the contributors to this release, especially
[@&#8203;som-sm](https://redirect.github.com/som-sm) 🙌

***

</details>

---

### Configuration

📅 **Schedule**: Branch creation - At any time (no schedule defined),
Automerge - At any time (no schedule defined).

🚦 **Automerge**: Disabled by config. Please merge this manually once you
are satisfied.

♻ **Rebasing**: Whenever PR becomes conflicted, or you tick the
rebase/retry checkbox.

🔕 **Ignore**: Close this PR and you won't be reminded about this update
again.

---

- [ ] <!-- rebase-check -->If you want to rebase/retry this PR, check
this box

---

This PR was generated by [Mend Renovate](https://mend.io/renovate/).
View the [repository job
log](https://developer.mend.io/github/unraid/api).

<!--renovate-debug:eyJjcmVhdGVkSW5WZXIiOiI0MS45Ny4xMCIsInVwZGF0ZWRJblZlciI6IjQxLjk3LjEwIiwidGFyZ2V0QnJhbmNoIjoibWFpbiIsImxhYmVscyI6W119-->

Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-09-15 11:24:31 -04:00
renovate[bot]
45fb53d040 chore(deps): update actions/setup-node action to v5 (#1680)
This PR contains the following updates:

| Package | Type | Update | Change |
|---|---|---|---|
| [actions/setup-node](https://redirect.github.com/actions/setup-node) |
action | major | `v4` -> `v5` |

---

### Release Notes

<details>
<summary>actions/setup-node (actions/setup-node)</summary>

###
[`v5`](https://redirect.github.com/actions/setup-node/compare/v4...v5)

[Compare
Source](https://redirect.github.com/actions/setup-node/compare/v4...v5)

</details>

---

### Configuration

📅 **Schedule**: Branch creation - At any time (no schedule defined),
Automerge - At any time (no schedule defined).

🚦 **Automerge**: Disabled by config. Please merge this manually once you
are satisfied.

♻ **Rebasing**: Whenever PR becomes conflicted, or you tick the
rebase/retry checkbox.

🔕 **Ignore**: Close this PR and you won't be reminded about this update
again.

---

- [ ] <!-- rebase-check -->If you want to rebase/retry this PR, check
this box

---

This PR was generated by [Mend Renovate](https://mend.io/renovate/).
View the [repository job
log](https://developer.mend.io/github/unraid/api).

<!--renovate-debug:eyJjcmVhdGVkSW5WZXIiOiI0MS45Ny4xMCIsInVwZGF0ZWRJblZlciI6IjQxLjk3LjEwIiwidGFyZ2V0QnJhbmNoIjoibWFpbiIsImxhYmVscyI6W119-->

---------

Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
Co-authored-by: Eli Bosley <ekbosley@gmail.com>
2025-09-15 11:24:31 -04:00
Eli Bosley
c855caa9b2 fix: improve vue mount speed by 10x
- Refactored teleport container management to be lazily created,
improving performance by avoiding unnecessary DOM manipulations.
- Updated `useTeleport` to dynamically determine the correct teleport
target based on mounted components.
- Removed the `ensureTeleportContainer` import from various components,
streamlining the mounting process.
- Adjusted the dropdown menu component to utilize a computed property
for teleport target management.
- Enhanced the component registry to support a unified app architecture,
replacing legacy mounting functions with a more efficient approach.

<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
## Summary by CodeRabbit

* **New Features**
  * Support bundles may include the GraphQL API log when present.
  * OS version data loads lazily when the header dropdown first opens.
* Many UI components now load on demand via a unified mounting approach.

* **Bug Fixes**
  * Dropdowns and modals consistently stack above other UI elements.
  * Server status layout fixes improve small-screen alignment.

* **Breaking Changes**
* Teleport/mounting APIs and public teleport helper were
consolidated/removed; integrations may need update.

* **Tests**
* Extensive new unit tests added for mounting, teleport, modals, and
REST log handling.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->

---------

Co-authored-by: Copilot Autofix powered by AI <62310815+github-advanced-security[bot]@users.noreply.github.com>
2025-09-15 11:24:31 -04:00
renovate[bot]
ba4a43aec8 fix(deps): update graphqlcodegenerator monorepo (major) (#1689) 2025-09-15 11:24:24 -04:00
45 changed files with 2071 additions and 1818 deletions

View File

@@ -51,21 +51,16 @@ jobs:
with:
fetch-depth: 0
- name: Install Node
uses: actions/setup-node@v4
with:
node-version-file: ".nvmrc"
- uses: pnpm/action-setup@v4
name: Install pnpm
with:
run_install: false
- name: Get pnpm store directory
id: pnpm-cache
shell: bash
run: |
echo "STORE_PATH=$(pnpm store path)" >> $GITHUB_OUTPUT
- name: Install Node
uses: actions/setup-node@v5
with:
node-version-file: ".nvmrc"
cache: 'pnpm'
- name: Get API Version
id: vars
@@ -76,14 +71,6 @@ jobs:
API_VERSION=$([[ -n "$IS_TAGGED" ]] && echo "$PACKAGE_LOCK_VERSION" || echo "${PACKAGE_LOCK_VERSION}+${GIT_SHA}")
echo "API_VERSION=${API_VERSION}" >> $GITHUB_OUTPUT
- uses: actions/cache@v4
name: Setup pnpm cache
with:
path: ${{ steps.pnpm-cache.outputs.STORE_PATH }}
key: ${{ runner.os }}-pnpm-store-${{ hashFiles('**/pnpm-lock.yaml') }}
restore-keys: |
${{ runner.os }}-pnpm-store-
- name: Install dependencies
run: |
cd ${{ github.workspace }}

View File

@@ -22,16 +22,17 @@ jobs:
- name: Checkout
uses: actions/checkout@v5
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: '22.18.0'
- uses: pnpm/action-setup@v4
name: Install pnpm
with:
run_install: false
- name: Setup Node.js
uses: actions/setup-node@v5
with:
node-version-file: ".nvmrc"
cache: 'pnpm'
- name: Cache APT Packages
uses: awalsh128/cache-apt-pkgs-action@v1.5.3
with:

View File

@@ -6,6 +6,10 @@ on:
branches:
- main
permissions:
contents: write
pull-requests: write
concurrency:
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
@@ -23,10 +27,16 @@ jobs:
with:
fetch-depth: 0
- name: Install pnpm
uses: pnpm/action-setup@v4
with:
run_install: false
- name: Install Node
uses: actions/setup-node@v4
uses: actions/setup-node@v5
with:
node-version-file: ".nvmrc"
cache: 'pnpm'
- name: Cache APT Packages
uses: awalsh128/cache-apt-pkgs-action@v1.5.3
@@ -34,25 +44,6 @@ jobs:
packages: bash procps python3 libvirt-dev jq zstd git build-essential libvirt-daemon-system php-cli
version: 1.0
- name: Install pnpm
uses: pnpm/action-setup@v4
with:
run_install: false
- name: Get pnpm store directory
id: pnpm-cache
shell: bash
run: |
echo "STORE_PATH=$(pnpm store path)" >> $GITHUB_OUTPUT
- uses: actions/cache@v4
name: Setup pnpm cache
with:
path: ${{ steps.pnpm-cache.outputs.STORE_PATH }}
key: ${{ runner.os }}-pnpm-store-${{ hashFiles('**/pnpm-lock.yaml') }}
restore-keys: |
${{ runner.os }}-pnpm-store-
- name: PNPM Install
run: pnpm install --frozen-lockfile
@@ -175,29 +166,16 @@ jobs:
- name: Checkout repo
uses: actions/checkout@v5
- name: Install Node
uses: actions/setup-node@v4
with:
node-version-file: ".nvmrc"
- uses: pnpm/action-setup@v4
name: Install pnpm
with:
run_install: false
- name: Get pnpm store directory
id: pnpm-cache
shell: bash
run: |
echo "STORE_PATH=$(pnpm store path)" >> $GITHUB_OUTPUT
- uses: actions/cache@v4
name: Setup pnpm cache
- name: Install Node
uses: actions/setup-node@v5
with:
path: ${{ steps.pnpm-cache.outputs.STORE_PATH }}
key: ${{ runner.os }}-pnpm-store-${{ hashFiles('**/pnpm-lock.yaml') }}
restore-keys: |
${{ runner.os }}-pnpm-store-
node-version-file: ".nvmrc"
cache: 'pnpm'
- name: Cache APT Packages
uses: awalsh128/cache-apt-pkgs-action@v1.5.3
@@ -228,7 +206,7 @@ jobs:
id: buildnumber
uses: onyxmueller/build-tag-number@v1
with:
token: ${{secrets.github_token}}
token: ${{secrets.UNRAID_BOT_GITHUB_ADMIN_TOKEN}}
prefix: ${{steps.vars.outputs.PACKAGE_LOCK_VERSION}}
- name: Build
@@ -252,29 +230,16 @@ jobs:
- name: Checkout repo
uses: actions/checkout@v5
- name: Install Node
uses: actions/setup-node@v4
with:
node-version-file: ".nvmrc"
- uses: pnpm/action-setup@v4
name: Install pnpm
with:
run_install: false
- name: Get pnpm store directory
id: pnpm-cache
shell: bash
run: |
echo "STORE_PATH=$(pnpm store path)" >> $GITHUB_OUTPUT
- uses: actions/cache@v4
name: Setup pnpm cache
- name: Install Node
uses: actions/setup-node@v5
with:
path: ${{ steps.pnpm-cache.outputs.STORE_PATH }}
key: ${{ runner.os }}-pnpm-store-${{ hashFiles('**/pnpm-lock.yaml') }}
restore-keys: |
${{ runner.os }}-pnpm-store-
node-version-file: ".nvmrc"
cache: 'pnpm'
- name: Cache APT Packages
uses: awalsh128/cache-apt-pkgs-action@v1.5.3
@@ -318,29 +283,16 @@ jobs:
echo VITE_UNRAID_NET=${{ secrets.VITE_UNRAID_NET }} >> .env
echo VITE_CALLBACK_KEY=${{ secrets.VITE_CALLBACK_KEY }} >> .env
- name: Install Node
uses: actions/setup-node@v4
with:
node-version-file: ".nvmrc"
- uses: pnpm/action-setup@v4
name: Install pnpm
with:
run_install: false
- name: Get pnpm store directory
id: pnpm-cache
shell: bash
run: |
echo "STORE_PATH=$(pnpm store path)" >> $GITHUB_OUTPUT
- uses: actions/cache@v4
name: Setup pnpm cache
- name: Install Node
uses: actions/setup-node@v5
with:
path: ${{ steps.pnpm-cache.outputs.STORE_PATH }}
key: ${{ runner.os }}-pnpm-store-${{ hashFiles('**/pnpm-lock.yaml') }}
restore-keys: |
${{ runner.os }}-pnpm-store-
node-version-file: ".nvmrc"
cache: 'pnpm'
- name: PNPM Install
run: |

View File

@@ -29,11 +29,6 @@ jobs:
contents: read
actions: read
steps:
- name: Set Timezone
uses: szenius/set-timezone@v2.0
with:
timezoneLinux: "America/Los_Angeles"
- name: Set PR number
id: pr_number
run: |

View File

@@ -28,9 +28,14 @@ jobs:
with:
latest: true
prerelease: false
- uses: actions/setup-node@v4
- uses: pnpm/action-setup@v4
name: Install pnpm
with:
node-version: '22.18.0'
run_install: false
- uses: actions/setup-node@v5
with:
node-version-file: ".nvmrc"
cache: 'pnpm'
- run: |
cat << 'EOF' > release-notes.txt
${{ steps.release-info.outputs.body }}

View File

@@ -1,5 +1,5 @@
{
"version": "4.19.1",
"version": "4.22.1",
"extraOrigins": [],
"sandbox": true,
"ssoSubIds": [],

View File

@@ -56,7 +56,7 @@
"@as-integrations/fastify": "2.1.1",
"@fastify/cookie": "11.0.2",
"@fastify/helmet": "13.0.1",
"@graphql-codegen/client-preset": "4.8.3",
"@graphql-codegen/client-preset": "5.0.0",
"@graphql-tools/load-files": "7.0.1",
"@graphql-tools/merge": "9.1.1",
"@graphql-tools/schema": "10.0.25",
@@ -103,7 +103,7 @@
"execa": "9.6.0",
"exit-hook": "4.0.0",
"fastify": "5.5.0",
"filenamify": "6.0.0",
"filenamify": "7.0.0",
"fs-extra": "11.3.1",
"glob": "11.0.3",
"global-agent": "3.0.0",
@@ -156,14 +156,14 @@
},
"devDependencies": {
"@eslint/js": "9.34.0",
"@graphql-codegen/add": "5.0.3",
"@graphql-codegen/cli": "5.0.7",
"@graphql-codegen/fragment-matcher": "5.1.0",
"@graphql-codegen/add": "6.0.0",
"@graphql-codegen/cli": "6.0.0",
"@graphql-codegen/fragment-matcher": "6.0.0",
"@graphql-codegen/import-types-preset": "3.0.1",
"@graphql-codegen/typed-document-node": "5.1.2",
"@graphql-codegen/typescript": "4.1.6",
"@graphql-codegen/typescript-operations": "4.6.1",
"@graphql-codegen/typescript-resolvers": "4.5.1",
"@graphql-codegen/typed-document-node": "6.0.0",
"@graphql-codegen/typescript": "5.0.0",
"@graphql-codegen/typescript-operations": "5.0.0",
"@graphql-codegen/typescript-resolvers": "5.0.0",
"@graphql-typed-document-node/core": "3.2.0",
"@ianvs/prettier-plugin-sort-imports": "4.6.3",
"@nestjs/testing": "11.1.6",
@@ -205,7 +205,7 @@
"rollup-plugin-node-externals": "8.1.0",
"supertest": "7.1.4",
"tsx": "4.20.5",
"type-fest": "4.41.0",
"type-fest": "5.0.0",
"typescript": "5.9.2",
"typescript-eslint": "8.41.0",
"unplugin-swc": "1.5.7",

View File

@@ -0,0 +1,350 @@
import { Test, TestingModule } from '@nestjs/testing';
import type { ReadStream, Stats } from 'node:fs';
import { createReadStream } from 'node:fs';
import { stat, writeFile } from 'node:fs/promises';
import { Readable } from 'node:stream';
import { execa, ExecaError } from 'execa';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import type { ApiReportData } from '@app/unraid-api/cli/api-report.service.js';
import {
getBannerPathIfPresent,
getCasePathIfPresent,
} from '@app/core/utils/images/image-file-helpers.js';
import { getters } from '@app/store/index.js';
import { ApiReportService } from '@app/unraid-api/cli/api-report.service.js';
import { RestService } from '@app/unraid-api/rest/rest.service.js';
vi.mock('node:fs');
vi.mock('node:fs/promises');
vi.mock('execa');
vi.mock('@app/store/index.js');
vi.mock('@app/core/utils/images/image-file-helpers.js', () => ({
getBannerPathIfPresent: vi.fn(),
getCasePathIfPresent: vi.fn(),
}));
describe('RestService', () => {
let service: RestService;
let apiReportService: ApiReportService;
beforeEach(async () => {
vi.clearAllMocks();
const module: TestingModule = await Test.createTestingModule({
providers: [
RestService,
{
provide: ApiReportService,
useValue: {
generateReport: vi.fn(),
},
},
],
}).compile();
service = module.get<RestService>(RestService);
apiReportService = module.get<ApiReportService>(ApiReportService);
});
describe('getLogs', () => {
const mockLogPath = '/usr/local/emhttp/logs/unraid-api';
const mockGraphqlApiLog = '/var/log/graphql-api.log';
const mockZipPath = '/usr/local/emhttp/logs/unraid-api.tar.gz';
beforeEach(() => {
vi.mocked(getters).paths = vi.fn().mockReturnValue({
'log-base': mockLogPath,
});
// Mock saveApiReport to avoid side effects
vi.spyOn(service as any, 'saveApiReport').mockResolvedValue(undefined);
});
it('should create and return log archive successfully', async () => {
const mockStream: ReadStream = Readable.from([]) as ReadStream;
vi.mocked(stat).mockImplementation((path) => {
if (path === mockLogPath || path === mockZipPath) {
return Promise.resolve({ isFile: () => true } as unknown as Stats);
}
return Promise.reject(new Error('File not found'));
});
vi.mocked(execa).mockResolvedValue({
stdout: '',
stderr: '',
exitCode: 0,
} as any);
vi.mocked(createReadStream).mockReturnValue(mockStream);
const result = await service.getLogs();
expect(execa).toHaveBeenCalledWith('tar', ['-czf', mockZipPath, mockLogPath], {
timeout: 60000,
reject: true,
});
expect(createReadStream).toHaveBeenCalledWith(mockZipPath);
expect(result).toBe(mockStream);
});
it('should include graphql-api.log when it exists', async () => {
vi.mocked(stat).mockImplementation((path) => {
if (path === mockLogPath || path === mockGraphqlApiLog || path === mockZipPath) {
return Promise.resolve({ isFile: () => true } as unknown as Stats);
}
return Promise.reject(new Error('File not found'));
});
vi.mocked(execa).mockResolvedValue({
stdout: '',
stderr: '',
exitCode: 0,
} as any);
vi.mocked(createReadStream).mockReturnValue(Readable.from([]) as ReadStream);
await service.getLogs();
expect(execa).toHaveBeenCalledWith(
'tar',
['-czf', mockZipPath, mockLogPath, mockGraphqlApiLog],
{
timeout: 60000,
reject: true,
}
);
});
it('should handle timeout errors with detailed message', async () => {
vi.mocked(stat).mockImplementation((path) => {
if (path === mockLogPath) {
return Promise.resolve({ isFile: () => true } as unknown as Stats);
}
return Promise.reject(new Error('File not found'));
});
const timeoutError = new Error('Command timed out') as ExecaError;
timeoutError.timedOut = true;
timeoutError.command =
'tar -czf /usr/local/emhttp/logs/unraid-api.tar.gz /usr/local/emhttp/logs/unraid-api';
timeoutError.exitCode = undefined;
timeoutError.stderr = '';
timeoutError.stdout = '';
vi.mocked(execa).mockRejectedValue(timeoutError);
await expect(service.getLogs()).rejects.toThrow('Tar command timed out after 60 seconds');
});
it('should handle command failure with exit code and stderr', async () => {
vi.mocked(stat).mockImplementation((path) => {
if (path === mockLogPath) {
return Promise.resolve({ isFile: () => true } as unknown as Stats);
}
return Promise.reject(new Error('File not found'));
});
const execError = new Error('Command failed') as ExecaError;
execError.exitCode = 1;
execError.command =
'tar -czf /usr/local/emhttp/logs/unraid-api.tar.gz /usr/local/emhttp/logs/unraid-api';
execError.stderr = 'tar: Cannot create archive';
execError.stdout = '';
execError.shortMessage = 'Command failed with exit code 1';
vi.mocked(execa).mockRejectedValue(execError);
await expect(service.getLogs()).rejects.toThrow('Tar command failed with exit code 1');
await expect(service.getLogs()).rejects.toThrow('tar: Cannot create archive');
});
it('should handle case when tar succeeds but zip file is not created', async () => {
vi.mocked(stat).mockImplementation((path) => {
if (path === mockLogPath) {
return Promise.resolve({ isFile: () => true } as unknown as Stats);
}
// Zip file doesn't exist after tar command
return Promise.reject(new Error('File not found'));
});
vi.mocked(execa).mockResolvedValue({
stdout: '',
stderr: '',
exitCode: 0,
} as any);
await expect(service.getLogs()).rejects.toThrow(
'Failed to create log zip - tar file not found after successful command'
);
});
it('should throw error when log path does not exist', async () => {
vi.mocked(stat).mockRejectedValue(new Error('File not found'));
await expect(service.getLogs()).rejects.toThrow('No logs to download');
});
it('should handle generic errors', async () => {
vi.mocked(stat).mockImplementation((path) => {
if (path === mockLogPath) {
return Promise.resolve({ isFile: () => true } as unknown as Stats);
}
return Promise.reject(new Error('File not found'));
});
const genericError = new Error('Unexpected error');
vi.mocked(execa).mockRejectedValue(genericError);
await expect(service.getLogs()).rejects.toThrow(
'Failed to create logs archive: Unexpected error'
);
});
it('should handle errors with stdout in addition to stderr', async () => {
vi.mocked(stat).mockImplementation((path) => {
if (path === mockLogPath) {
return Promise.resolve({ isFile: () => true } as unknown as Stats);
}
return Promise.reject(new Error('File not found'));
});
const execError = new Error('Command failed') as ExecaError;
execError.exitCode = 1;
execError.command =
'tar -czf /usr/local/emhttp/logs/unraid-api.tar.gz /usr/local/emhttp/logs/unraid-api';
execError.stderr = 'tar: Error';
execError.stdout = 'Processing archive...';
execError.shortMessage = 'Command failed with exit code 1';
vi.mocked(execa).mockRejectedValue(execError);
await expect(service.getLogs()).rejects.toThrow('Stdout: Processing archive');
});
});
describe('saveApiReport', () => {
it('should generate and save API report', async () => {
const mockReport: ApiReportData = {
timestamp: new Date().toISOString(),
connectionStatus: { running: 'yes' },
system: {
name: 'Test Server',
version: '6.12.0',
machineId: 'test-machine-id',
},
connect: {
installed: false,
},
config: {
valid: true,
},
services: {
cloud: null,
minigraph: null,
allServices: [],
},
};
const mockPath = '/test/report.json';
vi.mocked(apiReportService.generateReport).mockResolvedValue(mockReport);
vi.mocked(writeFile).mockResolvedValue(undefined);
await service.saveApiReport(mockPath);
expect(apiReportService.generateReport).toHaveBeenCalled();
expect(writeFile).toHaveBeenCalledWith(
mockPath,
JSON.stringify(mockReport, null, 2),
'utf-8'
);
});
it('should handle errors when generating report', async () => {
const mockPath = '/test/report.json';
vi.mocked(apiReportService.generateReport).mockRejectedValue(
new Error('Report generation failed')
);
// Should not throw, just log warning
await expect(service.saveApiReport(mockPath)).resolves.toBeUndefined();
expect(apiReportService.generateReport).toHaveBeenCalled();
});
});
describe('getCustomizationPath', () => {
it('should return banner path when type is banner', async () => {
const mockBannerPath = '/path/to/banner.png';
vi.mocked(getBannerPathIfPresent).mockResolvedValue(mockBannerPath);
const result = await service.getCustomizationPath('banner');
expect(getBannerPathIfPresent).toHaveBeenCalled();
expect(result).toBe(mockBannerPath);
});
it('should return case path when type is case', async () => {
const mockCasePath = '/path/to/case.png';
vi.mocked(getCasePathIfPresent).mockResolvedValue(mockCasePath);
const result = await service.getCustomizationPath('case');
expect(getCasePathIfPresent).toHaveBeenCalled();
expect(result).toBe(mockCasePath);
});
it('should return null when no banner found', async () => {
vi.mocked(getBannerPathIfPresent).mockResolvedValue(null);
const result = await service.getCustomizationPath('banner');
expect(result).toBeNull();
});
it('should return null when no case found', async () => {
vi.mocked(getCasePathIfPresent).mockResolvedValue(null);
const result = await service.getCustomizationPath('case');
expect(result).toBeNull();
});
});
describe('getCustomizationStream', () => {
it('should return read stream for banner', async () => {
const mockPath = '/path/to/banner.png';
const mockStream: ReadStream = Readable.from([]) as ReadStream;
vi.mocked(getBannerPathIfPresent).mockResolvedValue(mockPath);
vi.mocked(createReadStream).mockReturnValue(mockStream);
const result = await service.getCustomizationStream('banner');
expect(getBannerPathIfPresent).toHaveBeenCalled();
expect(createReadStream).toHaveBeenCalledWith(mockPath);
expect(result).toBe(mockStream);
});
it('should return read stream for case', async () => {
const mockPath = '/path/to/case.png';
const mockStream: ReadStream = Readable.from([]) as ReadStream;
vi.mocked(getCasePathIfPresent).mockResolvedValue(mockPath);
vi.mocked(createReadStream).mockReturnValue(mockStream);
const result = await service.getCustomizationStream('case');
expect(getCasePathIfPresent).toHaveBeenCalled();
expect(createReadStream).toHaveBeenCalledWith(mockPath);
expect(result).toBe(mockStream);
});
it('should throw error when no banner found', async () => {
vi.mocked(getBannerPathIfPresent).mockResolvedValue(null);
await expect(service.getCustomizationStream('banner')).rejects.toThrow('No banner found');
});
it('should throw error when no case found', async () => {
vi.mocked(getCasePathIfPresent).mockResolvedValue(null);
await expect(service.getCustomizationStream('case')).rejects.toThrow('No case found');
});
});
});

View File

@@ -4,6 +4,7 @@ import { createReadStream } from 'node:fs';
import { stat, writeFile } from 'node:fs/promises';
import { join } from 'node:path';
import type { ExecaError } from 'execa';
import { execa } from 'execa';
import {
@@ -31,6 +32,8 @@ export class RestService {
async getLogs(): Promise<ReadStream> {
const logPath = getters.paths()['log-base'];
const graphqlApiLog = '/var/log/graphql-api.log';
try {
await this.saveApiReport(join(logPath, 'report.json'));
} catch (error) {
@@ -41,16 +44,62 @@ export class RestService {
const logPathExists = Boolean(await stat(logPath).catch(() => null));
if (logPathExists) {
try {
await execa('tar', ['-czf', zipToWrite, logPath]);
// Build tar command arguments
const tarArgs = ['-czf', zipToWrite, logPath];
// Check if graphql-api.log exists and add it to the archive
const graphqlLogExists = Boolean(await stat(graphqlApiLog).catch(() => null));
if (graphqlLogExists) {
tarArgs.push(graphqlApiLog);
this.logger.debug('Including graphql-api.log in archive');
}
// Execute tar with timeout and capture output
await execa('tar', tarArgs, {
timeout: 60000, // 60 seconds timeout for tar operation
reject: true, // Throw on non-zero exit (default behavior)
});
const tarFileExists = Boolean(await stat(zipToWrite).catch(() => null));
if (tarFileExists) {
return createReadStream(zipToWrite);
} else {
throw new Error('Failed to create log zip');
throw new Error(
'Failed to create log zip - tar file not found after successful command'
);
}
} catch (error) {
throw new Error('Failed to create logs');
// Build detailed error message with execa's built-in error info
let errorMessage = 'Failed to create logs archive';
if (error && typeof error === 'object' && 'command' in error) {
const execaError = error as ExecaError;
if (execaError.timedOut) {
errorMessage = `Tar command timed out after 60 seconds. Command: ${execaError.command}`;
} else if (execaError.exitCode !== undefined) {
errorMessage = `Tar command failed with exit code ${execaError.exitCode}. Command: ${execaError.command}`;
}
// Add stderr/stdout if available
if (execaError.stderr) {
errorMessage += `. Stderr: ${execaError.stderr}`;
}
if (execaError.stdout) {
errorMessage += `. Stdout: ${execaError.stdout}`;
}
// Include the short message from execa
if (execaError.shortMessage) {
errorMessage += `. Details: ${execaError.shortMessage}`;
}
} else if (error instanceof Error) {
errorMessage += `: ${error.message}`;
}
this.logger.error(errorMessage, error);
throw new Error(errorMessage);
}
} else {
throw new Error('No logs to download');

View File

@@ -63,8 +63,17 @@
"pre-commit": "pnpm lint-staged"
},
"lint-staged": {
"*.{js,jsx,ts,tsx,vue}": [
"pnpm lint:fix"
"api/**/*.{js,ts}": [
"pnpm --filter api lint:fix"
],
"web/**/*.{js,ts,tsx,vue}": [
"pnpm --filter web lint:fix"
],
"unraid-ui/**/*.{js,ts,tsx,vue}": [
"pnpm --filter @unraid/ui lint:fix"
],
"packages/**/*.{js,ts}": [
"eslint --fix"
]
},
"packageManager": "pnpm@10.15.0"

View File

@@ -26,7 +26,7 @@
"devDependencies": {
"@apollo/client": "3.14.0",
"@faker-js/faker": "10.0.0",
"@graphql-codegen/cli": "5.0.7",
"@graphql-codegen/cli": "6.0.0",
"@graphql-typed-document-node/core": "3.2.0",
"@ianvs/prettier-plugin-sort-imports": "4.6.3",
"@jsonforms/core": "3.6.0",
@@ -60,7 +60,7 @@
"prettier": "3.6.2",
"rimraf": "6.0.1",
"rxjs": "7.8.2",
"type-fest": "4.41.0",
"type-fest": "5.0.0",
"typescript": "5.9.2",
"undici": "7.15.0",
"vitest": "3.2.4",

View File

@@ -46,7 +46,7 @@
"nest-authz": "2.17.0",
"pify": "6.1.0",
"rimraf": "6.0.1",
"type-fest": "4.41.0",
"type-fest": "5.0.0",
"typescript": "5.9.2",
"vitest": "3.2.4",
"ws": "8.18.3"

View File

@@ -5,7 +5,7 @@
"dependencies": {
"commander": "14.0.0",
"conventional-changelog": "7.1.1",
"conventional-changelog-conventionalcommits": "^9.1.0",
"conventional-changelog-conventionalcommits": "9.1.0",
"date-fns": "4.1.0",
"glob": "11.0.3",
"html-sloppy-escaper": "0.1.0",
@@ -33,8 +33,9 @@
"env:validate": "test -f .env || (echo 'Error: .env file missing. Run npm run env:init first' && exit 1)",
"env:clean": "rm -f .env",
"// Testing": "",
"test": "vitest && pnpm run test:extractor",
"test:extractor": "bash ./tests/test-extractor.sh"
"test": "vitest && pnpm run test:extractor && pnpm run test:shell-detection",
"test:extractor": "bash ./tests/test-extractor.sh",
"test:shell-detection": "bash ./tests/test-shell-detection.sh"
},
"devDependencies": {
"http-server": "14.1.1",

View File

@@ -6,11 +6,19 @@
check_shell() {
# This script runs with #!/bin/bash shebang
# On Unraid, users may configure bash to load other shells through .bashrc
# We check if the current process ($$) is actually bash, not another shell
# Using $$ is correct here - we need to detect if THIS process is running the expected bash
# We need to check if the interpreter running this script is actually bash
# Use readlink on /proc to find the actual interpreter, not the script name
local current_shell
current_shell=$(ps -o comm= -p $$)
# Get the actual interpreter from /proc
if [ -e "/proc/$$/exe" ]; then
current_shell=$(readlink "/proc/$$/exe")
else
# Fallback to checking the current process if /proc isn't available
# Note: This may return the script name on some systems
current_shell=$(ps -o comm= -p $$)
fi
# Remove any path and get just the shell name
current_shell=$(basename "$current_shell")

View File

@@ -0,0 +1,159 @@
#!/bin/bash
# Test script for shell detection logic in verify_install.sh
set -e
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
VERIFY_SCRIPT="$SCRIPT_DIR/../source/dynamix.unraid.net/usr/local/share/dynamix.unraid.net/install/scripts/verify_install.sh"
# Colors for output
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
NC='\033[0m' # No Color
# Test counter
TESTS_RUN=0
TESTS_PASSED=0
# Helper function to run a test
run_test() {
local test_name="$1"
local test_cmd="$2"
local expected_result="$3"
TESTS_RUN=$((TESTS_RUN + 1))
echo -n "Testing: $test_name ... "
# Run the test and capture exit code
set +e
output=$($test_cmd 2>&1)
result=$?
set -e
if [ "$result" -eq "$expected_result" ]; then
echo -e "${GREEN}PASS${NC}"
TESTS_PASSED=$((TESTS_PASSED + 1))
else
echo -e "${RED}FAIL${NC}"
echo " Expected exit code: $expected_result, Got: $result"
echo " Output: $output"
fi
}
# Extract just the check_shell function from verify_install.sh
extract_check_shell() {
cat << 'EOF'
#!/bin/bash
check_shell() {
# This script runs with #!/bin/bash shebang
# On Unraid, users may configure bash to load other shells through .bashrc
# We need to check if the interpreter running this script is actually bash
# Use readlink on /proc to find the actual interpreter, not the script name
local current_shell
# Get the actual interpreter from /proc
if [ -e "/proc/$$/exe" ]; then
current_shell=$(readlink "/proc/$$/exe")
else
# Fallback to checking the current process if /proc isn't available
# Note: This may return the script name on some systems
current_shell=$(ps -o comm= -p $$)
fi
# Remove any path and get just the shell name
current_shell=$(basename "$current_shell")
if [[ "$current_shell" != "bash" ]]; then
echo "Unsupported shell detected: $current_shell" >&2
echo "Unraid scripts require bash but your system is configured to use $current_shell for scripts." >&2
echo "This can cause infinite loops or unexpected behavior when Unraid scripts execute." >&2
echo "Please configure $current_shell to only activate for interactive shells." >&2
echo "Add this check to your ~/.bashrc or /etc/profile before starting $current_shell:" >&2
echo " [[ \$- == *i* ]] && exec $current_shell" >&2
echo "This ensures $current_shell only starts for interactive sessions, not scripts." >&2
exit 1
fi
}
check_shell
EOF
}
echo "=== Shell Detection Tests ==="
echo
# Test 1: Running with bash should succeed
echo "Test 1: Direct bash execution"
TEMP_SCRIPT=$(mktemp)
extract_check_shell > "$TEMP_SCRIPT"
chmod +x "$TEMP_SCRIPT"
run_test "Bash interpreter (should pass)" "bash $TEMP_SCRIPT" 0
rm -f "$TEMP_SCRIPT"
# Test 2: Check that the actual verify_install.sh script works with bash
echo "Test 2: Verify install script with bash"
if [ -f "$VERIFY_SCRIPT" ]; then
# Create a modified version that only runs check_shell
TEMP_VERIFY=$(mktemp)
sed -n '1,/^check_shell$/p' "$VERIFY_SCRIPT" > "$TEMP_VERIFY"
echo "exit 0" >> "$TEMP_VERIFY"
chmod +x "$TEMP_VERIFY"
run_test "Verify install script shell check" "bash $TEMP_VERIFY" 0
rm -f "$TEMP_VERIFY"
else
echo -e "${YELLOW}SKIP${NC} - verify_install.sh not found"
fi
# Test 3: Simulate non-bash shell (if available)
echo "Test 3: Non-bash shell simulation"
if command -v sh >/dev/null 2>&1 && [ "$(readlink -f "$(command -v sh)")" != "$(readlink -f "$(command -v bash)")" ]; then
TEMP_SCRIPT=$(mktemp)
# Create a test that will fail if sh is detected
cat << 'EOF' > "$TEMP_SCRIPT"
#!/bin/sh
# This simulates what would happen if a non-bash shell was detected
current_shell=$(basename "$(readlink -f /proc/$$/exe 2>/dev/null || echo sh)")
if [ "$current_shell" != "bash" ]; then
echo "Detected non-bash shell: $current_shell" >&2
exit 1
fi
exit 0
EOF
chmod +x "$TEMP_SCRIPT"
run_test "Non-bash shell detection" "sh $TEMP_SCRIPT" 1
rm -f "$TEMP_SCRIPT"
else
echo -e "${YELLOW}SKIP${NC} - sh not available or is symlinked to bash"
fi
# Test 4: Check /proc availability (informational only, not a failure)
echo "Test 4: /proc filesystem check"
if [ -e "/proc/$$/exe" ]; then
echo -e "${GREEN}INFO${NC} - /proc filesystem is available"
else
echo -e "${YELLOW}INFO${NC} - /proc filesystem not available, fallback to ps will be used"
fi
# Test 5: Verify the script name is not detected as shell
echo "Test 5: Script name not detected as shell"
TEMP_SCRIPT=$(mktemp -t verify_install.XXXXXX)
extract_check_shell > "$TEMP_SCRIPT"
chmod +x "$TEMP_SCRIPT"
# This should pass because it's still bash, even though the script is named verify_install
run_test "Script named verify_install (should still pass)" "bash $TEMP_SCRIPT" 0
rm -f "$TEMP_SCRIPT"
echo
echo "=== Test Summary ==="
echo "Tests run: $TESTS_RUN"
echo "Tests passed: $TESTS_PASSED"
echo "Tests failed: $((TESTS_RUN - TESTS_PASSED))"
if [ "$TESTS_PASSED" -eq "$TESTS_RUN" ]; then
echo -e "${GREEN}All tests passed!${NC}"
exit 0
else
echo -e "${RED}Some tests failed${NC}"
exit 1
fi

639
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

@@ -104,6 +104,7 @@ eslint.configs.recommended, ...tseslint.configs.recommended, // TypeScript Files
parser: tseslint.parser,
parserOptions: {
...commonLanguageOptions,
tsconfigRootDir: import.meta.dirname,
ecmaFeatures: {
jsx: true,
},
@@ -128,6 +129,7 @@ eslint.configs.recommended, ...tseslint.configs.recommended, // TypeScript Files
parserOptions: {
...commonLanguageOptions,
parser: tseslint.parser,
tsconfigRootDir: import.meta.dirname,
ecmaFeatures: {
jsx: true,
},

View File

@@ -32,7 +32,7 @@ const { teleportTarget } = useTeleport();
v-bind="forwarded"
:class="
cn(
'bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 border-muted z-50 min-w-32 overflow-hidden rounded-lg border p-1 shadow-md',
'bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 border-muted z-[103] min-w-32 overflow-hidden rounded-lg border p-1 shadow-md',
props.class
)
"

View File

@@ -0,0 +1,169 @@
import useTeleport from '@/composables/useTeleport';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
// Mock Vue's computed while preserving other exports
vi.mock('vue', async () => ({
...(await vi.importActual('vue')),
computed: vi.fn((fn) => {
const result = { value: fn() };
return result;
}),
}));
describe('useTeleport', () => {
beforeEach(() => {
// Clear the DOM before each test
document.body.innerHTML = '';
document.head.innerHTML = '';
vi.clearAllMocks();
});
afterEach(() => {
vi.restoreAllMocks();
});
it('should return teleportTarget computed property', () => {
const { teleportTarget } = useTeleport();
expect(teleportTarget).toBeDefined();
expect(teleportTarget).toHaveProperty('value');
});
it('should return #modals when element with id="modals" exists', () => {
// Create element with id="modals"
const modalsDiv = document.createElement('div');
modalsDiv.id = 'modals';
document.body.appendChild(modalsDiv);
const { teleportTarget } = useTeleport();
expect(teleportTarget.value).toBe('#modals');
});
it('should prioritize #modals id over mounted unraid-modals', () => {
// Create both elements
const modalsDiv = document.createElement('div');
modalsDiv.id = 'modals';
document.body.appendChild(modalsDiv);
const unraidModals = document.createElement('unraid-modals');
unraidModals.setAttribute('data-vue-mounted', 'true');
document.body.appendChild(unraidModals);
const { teleportTarget } = useTeleport();
expect(teleportTarget.value).toBe('#modals');
});
it('should return mounted unraid-modals with inner #modals div', () => {
// Create mounted unraid-modals with inner modals div
const unraidModals = document.createElement('unraid-modals');
unraidModals.setAttribute('data-vue-mounted', 'true');
const innerModals = document.createElement('div');
innerModals.id = 'modals';
unraidModals.appendChild(innerModals);
document.body.appendChild(unraidModals);
const { teleportTarget } = useTeleport();
expect(teleportTarget.value).toBe('#modals');
});
it('should add id to mounted unraid-modals when no inner modals div exists', () => {
// Create mounted unraid-modals without inner div
const unraidModals = document.createElement('unraid-modals');
unraidModals.setAttribute('data-vue-mounted', 'true');
document.body.appendChild(unraidModals);
const { teleportTarget } = useTeleport();
expect(unraidModals.id).toBe('unraid-modals-teleport-target');
expect(teleportTarget.value).toBe('#unraid-modals-teleport-target');
});
it('should use existing id of mounted unraid-modals if present', () => {
// Create mounted unraid-modals with existing id
const unraidModals = document.createElement('unraid-modals');
unraidModals.setAttribute('data-vue-mounted', 'true');
unraidModals.id = 'custom-modals-id';
document.body.appendChild(unraidModals);
const { teleportTarget } = useTeleport();
expect(teleportTarget.value).toBe('#custom-modals-id');
});
it('should ignore unmounted unraid-modals elements', () => {
// Create unmounted unraid-modals (without data-vue-mounted attribute)
const unraidModals = document.createElement('unraid-modals');
document.body.appendChild(unraidModals);
const { teleportTarget } = useTeleport();
expect(teleportTarget.value).toBe('body');
});
it('should ignore unraid-modals with data-vue-mounted="false"', () => {
// Create unraid-modals with data-vue-mounted="false"
const unraidModals = document.createElement('unraid-modals');
unraidModals.setAttribute('data-vue-mounted', 'false');
document.body.appendChild(unraidModals);
const { teleportTarget } = useTeleport();
expect(teleportTarget.value).toBe('body');
});
it('should return body as fallback when no suitable target exists', () => {
// No elements in DOM
const { teleportTarget } = useTeleport();
expect(teleportTarget.value).toBe('body');
});
it('should handle multiple unraid-modals elements correctly', () => {
// Create multiple unraid-modals, only one mounted
const unmountedModals1 = document.createElement('unraid-modals');
document.body.appendChild(unmountedModals1);
const mountedModals = document.createElement('unraid-modals');
mountedModals.setAttribute('data-vue-mounted', 'true');
mountedModals.id = 'mounted-modals';
document.body.appendChild(mountedModals);
const unmountedModals2 = document.createElement('unraid-modals');
document.body.appendChild(unmountedModals2);
const { teleportTarget } = useTeleport();
expect(teleportTarget.value).toBe('#mounted-modals');
});
it('should handle nested modal elements correctly', () => {
// Create nested structure
const container = document.createElement('div');
const unraidModals = document.createElement('unraid-modals');
unraidModals.setAttribute('data-vue-mounted', 'true');
const innerDiv = document.createElement('div');
const innerModals = document.createElement('div');
innerModals.id = 'modals';
innerDiv.appendChild(innerModals);
unraidModals.appendChild(innerDiv);
container.appendChild(unraidModals);
document.body.appendChild(container);
const { teleportTarget } = useTeleport();
expect(teleportTarget.value).toBe('#modals');
});
it('should be reactive to DOM changes', () => {
const { teleportTarget } = useTeleport();
// Initially should be body
expect(teleportTarget.value).toBe('body');
// Add modals element
const modalsDiv = document.createElement('div');
modalsDiv.id = 'modals';
document.body.appendChild(modalsDiv);
// Recreate the composable to test updated DOM state
const { teleportTarget: newTeleportTarget } = useTeleport();
expect(newTeleportTarget.value).toBe('#modals');
});
});

View File

@@ -1,12 +1,31 @@
import { ensureTeleportContainer } from '@/helpers/ensure-teleport-container';
import { onMounted, ref } from 'vue';
import { computed } from 'vue';
const useTeleport = () => {
const teleportTarget = ref<string | HTMLElement>('body');
// Computed property that finds the correct teleport target
const teleportTarget = computed(() => {
// #modals should be unique (id), but let's be defensive
const modalsElement = document.getElementById('modals');
if (modalsElement) return `#modals`;
onMounted(() => {
const container = ensureTeleportContainer();
teleportTarget.value = container;
// Find only mounted unraid-modals components (data-vue-mounted="true")
// This ensures we don't target unmounted or duplicate elements
const mountedModals = document.querySelector('unraid-modals[data-vue-mounted="true"]');
if (mountedModals) {
// Check if it has the inner #modals div
const innerModals = mountedModals.querySelector('#modals');
if (innerModals && innerModals.id) {
return `#${innerModals.id}`;
}
// Use the mounted component itself as fallback
// Add a unique identifier if it doesn't have one
if (!mountedModals.id) {
mountedModals.id = 'unraid-modals-teleport-target';
}
return `#${mountedModals.id}`;
}
// Final fallback to body - modals component not mounted yet
return 'body';
});
return {

View File

@@ -1,23 +0,0 @@
/**
* Ensures the teleport container exists in the DOM.
* This is used by both the standalone mount script and unraid-ui components
* to ensure modals and other teleported content have a target.
*/
export function ensureTeleportContainer(): HTMLElement {
const containerId = 'unraid-teleport-container';
// Check if container already exists
let container = document.getElementById(containerId);
// If it doesn't exist, create it
if (!container) {
container = document.createElement('div');
container.id = containerId;
container.style.position = 'relative';
container.classList.add('unapi');
container.style.zIndex = '999999'; // Very high z-index to ensure it's always on top
document.body.appendChild(container);
}
return container;
}

View File

@@ -15,6 +15,3 @@ export * from '@/lib/utils';
export { default as useTeleport } from '@/composables/useTeleport';
export { useToast } from '@/composables/useToast';
export type { ToastInstance } from '@/composables/useToast';
// Helpers
export { ensureTeleportContainer } from '@/helpers/ensure-teleport-container';

View File

@@ -51,10 +51,6 @@
"exclude": [
"node_modules",
"**/*.copy.vue",
"**/*copy.vue",
"**/*.test.ts",
"**/*.spec.ts",
"**/*.test.tsx",
"**/*.spec.tsx"
"**/*copy.vue"
]
}

View File

@@ -19,6 +19,17 @@ export default function createConfig() {
dts({
insertTypesEntry: true,
include: ['src/**/*.ts', 'src/**/*.vue'],
exclude: [
'src/**/*.test.ts',
'src/**/*.spec.ts',
'src/**/*.test.tsx',
'src/**/*.spec.tsx',
'src/**/*.test.vue',
'src/**/*.spec.vue',
'src/**/*.stories.*',
'src/**/*.stories.{ts,tsx,vue}',
'src/**/__tests__/**',
],
outDir: 'dist',
rollupTypes: true,
copyDtsFiles: true,
@@ -31,8 +42,6 @@ export default function createConfig() {
external: [
'vue',
'tailwindcss',
'ajv',
'ajv-errors',
...(process.env.npm_lifecycle_script?.includes('storybook') ? [/^storybook\//] : []),
],
input: {
@@ -77,6 +86,9 @@ export default function createConfig() {
'@/theme': resolve(__dirname, './src/theme'),
},
},
optimizeDeps: {
include: ['ajv', 'ajv-errors'],
},
test: {
environment: 'happy-dom',
include: ['src/**/*.{test,spec}.{js,mjs,cjs,ts,mts,cts,jsx,tsx}'],

View File

@@ -156,4 +156,28 @@ describe('HeaderOsVersion', () => {
expect(findUpdateStatusComponent()).toBeNull();
});
it('removes logo class from logo wrapper on mount', async () => {
// Create a mock logo element
const logoElement = document.createElement('div');
logoElement.classList.add('logo');
document.body.appendChild(logoElement);
// Mount component
const newWrapper = mount(HeaderOsVersion, {
global: {
plugins: [testingPinia],
},
});
// Wait for nextTick to allow onMounted to complete
await nextTick();
await nextTick(); // Double nextTick since onMounted uses nextTick internally
expect(logoElement.classList.contains('logo')).toBe(false);
// Cleanup
newWrapper.unmount();
document.body.removeChild(logoElement);
});
});

View File

@@ -0,0 +1,232 @@
import { nextTick } from 'vue';
import { createPinia, setActivePinia } from 'pinia';
import { mount } from '@vue/test-utils';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import type { VueWrapper } from '@vue/test-utils';
import type { Pinia } from 'pinia';
import Modals from '~/components/Modals.standalone.vue';
import { useCallbackActionsStore } from '~/store/callbackActions';
import { useTrialStore } from '~/store/trial';
import { useUpdateOsStore } from '~/store/updateOs';
// Mock child components
vi.mock('~/components/Activation/ActivationModal.vue', () => ({
default: {
name: 'ActivationModal',
props: ['t'],
template: '<div>ActivationModal</div>',
},
}));
vi.mock('~/components/UpdateOs/ChangelogModal.vue', () => ({
default: {
name: 'UpdateOsChangelogModal',
props: ['t', 'open'],
template: '<div v-if="open">ChangelogModal</div>',
},
}));
vi.mock('~/components/UpdateOs/CheckUpdateResponseModal.vue', () => ({
default: {
name: 'UpdateOsCheckUpdateResponseModal',
props: ['t', 'open'],
template: '<div v-if="open">CheckUpdateResponseModal</div>',
},
}));
vi.mock('~/components/UserProfile/CallbackFeedback.vue', () => ({
default: {
name: 'UpcCallbackFeedback',
props: ['t', 'open'],
template: '<div v-if="open">CallbackFeedback</div>',
},
}));
vi.mock('~/components/UserProfile/Trial.vue', () => ({
default: {
name: 'UpcTrial',
props: ['t', 'open'],
template: '<div v-if="open">Trial</div>',
},
}));
vi.mock('vue-i18n', () => ({
useI18n: () => ({
t: (key: string) => key,
}),
}));
describe('Modals.standalone.vue', () => {
let wrapper: VueWrapper;
let pinia: Pinia;
beforeEach(() => {
pinia = createPinia();
setActivePinia(pinia);
wrapper = mount(Modals, {
global: {
plugins: [pinia],
},
});
});
afterEach(() => {
wrapper?.unmount();
vi.clearAllMocks();
});
it('should render modals container with correct id and ref', () => {
const modalsDiv = wrapper.find('#modals');
expect(modalsDiv.exists()).toBe(true);
expect(modalsDiv.attributes('class')).toContain('relative');
expect(modalsDiv.attributes('class')).toContain('z-[999999]');
});
it('should render all modal components', () => {
expect(wrapper.findComponent({ name: 'UpcCallbackFeedback' }).exists()).toBe(true);
expect(wrapper.findComponent({ name: 'UpcTrial' }).exists()).toBe(true);
expect(wrapper.findComponent({ name: 'UpdateOsCheckUpdateResponseModal' }).exists()).toBe(true);
expect(wrapper.findComponent({ name: 'UpdateOsChangelogModal' }).exists()).toBe(true);
expect(wrapper.findComponent({ name: 'ActivationModal' }).exists()).toBe(true);
});
it('should pass correct props to CallbackFeedback based on store state', async () => {
const callbackStore = useCallbackActionsStore();
callbackStore.callbackStatus = 'loading';
await nextTick();
const callbackFeedback = wrapper.findComponent({ name: 'UpcCallbackFeedback' });
expect(callbackFeedback.props('open')).toBe(true);
callbackStore.callbackStatus = 'ready';
await nextTick();
expect(callbackFeedback.props('open')).toBe(false);
});
it('should pass correct props to Trial modal based on store state', async () => {
const trialStore = useTrialStore();
// trialModalVisible is computed based on trialStatus
trialStore.trialStatus = 'trialStart';
await nextTick();
const trialModal = wrapper.findComponent({ name: 'UpcTrial' });
expect(trialModal.props('open')).toBe(true);
trialStore.trialStatus = 'ready';
await nextTick();
expect(trialModal.props('open')).toBe(false);
});
it('should pass correct props to UpdateOs modal based on store state', async () => {
const updateOsStore = useUpdateOsStore();
updateOsStore.setModalOpen(true);
await nextTick();
const updateOsModal = wrapper.findComponent({ name: 'UpdateOsCheckUpdateResponseModal' });
expect(updateOsModal.props('open')).toBe(true);
updateOsStore.setModalOpen(false);
await nextTick();
expect(updateOsModal.props('open')).toBe(false);
});
it('should pass correct props to Changelog modal based on store state', async () => {
const updateOsStore = useUpdateOsStore();
// changelogModalVisible is computed based on releaseForUpdate
updateOsStore.setReleaseForUpdate({
version: '6.13.0',
name: 'Unraid 6.13.0',
date: '2024-01-01',
isNewer: true,
isEligible: true,
changelog: null,
sha256: null,
});
await nextTick();
const changelogModal = wrapper.findComponent({ name: 'UpdateOsChangelogModal' });
expect(changelogModal.props('open')).toBe(true);
updateOsStore.setReleaseForUpdate(null);
await nextTick();
expect(changelogModal.props('open')).toBe(false);
});
it('should pass translation function to all modals', () => {
const components = [
'UpcCallbackFeedback',
'UpcTrial',
'UpdateOsCheckUpdateResponseModal',
'UpdateOsChangelogModal',
'ActivationModal',
];
components.forEach((componentName) => {
const component = wrapper.findComponent({ name: componentName });
expect(component.props('t')).toBeDefined();
expect(typeof component.props('t')).toBe('function');
});
});
it('should use computed properties for reactive store access', async () => {
// Test that computed properties react to store changes
const callbackStore = useCallbackActionsStore();
const trialStore = useTrialStore();
const updateOsStore = useUpdateOsStore();
// Initially all should be closed/default
expect(wrapper.findComponent({ name: 'UpcCallbackFeedback' }).props('open')).toBe(false);
expect(wrapper.findComponent({ name: 'UpcTrial' }).props('open')).toBe(false);
expect(wrapper.findComponent({ name: 'UpdateOsCheckUpdateResponseModal' }).props('open')).toBe(
false
);
expect(wrapper.findComponent({ name: 'UpdateOsChangelogModal' }).props('open')).toBe(false);
// Update all stores using proper methods
callbackStore.callbackStatus = 'loading';
trialStore.trialStatus = 'trialStart';
updateOsStore.setModalOpen(true);
updateOsStore.setReleaseForUpdate({
version: '6.13.0',
name: 'Unraid 6.13.0',
date: '2024-01-01',
isNewer: true,
isEligible: true,
changelog: null,
sha256: null,
});
await nextTick();
// All should be open now
expect(wrapper.findComponent({ name: 'UpcCallbackFeedback' }).props('open')).toBe(true);
expect(wrapper.findComponent({ name: 'UpcTrial' }).props('open')).toBe(true);
expect(wrapper.findComponent({ name: 'UpdateOsCheckUpdateResponseModal' }).props('open')).toBe(true);
expect(wrapper.findComponent({ name: 'UpdateOsChangelogModal' }).props('open')).toBe(true);
});
it('should render modals container even when all modals are closed', () => {
const callbackStore = useCallbackActionsStore();
const trialStore = useTrialStore();
const updateOsStore = useUpdateOsStore();
// Set all modals to closed state
callbackStore.callbackStatus = 'ready';
trialStore.trialStatus = 'ready';
updateOsStore.setModalOpen(false);
updateOsStore.setReleaseForUpdate(null);
const modalsDiv = wrapper.find('#modals');
expect(modalsDiv.exists()).toBe(true);
// Container should still exist
expect(wrapper.findComponent({ name: 'ActivationModal' }).exists()).toBe(true);
});
});

View File

@@ -0,0 +1,184 @@
import { describe, expect, it, vi } from 'vitest';
// Mock Vue's defineAsyncComponent
vi.mock('vue', () => ({
defineAsyncComponent: vi.fn((loader) => ({ loader, __asyncComponent: true })),
}));
// Mock CSS imports
vi.mock('~/assets/main.css', () => ({}));
vi.mock('@unraid/ui/styles', () => ({}));
// Mock all component imports
vi.mock('@/components/HeaderOsVersion.standalone.vue', () => ({ default: 'HeaderOsVersion' }));
vi.mock('@/components/UserProfile.standalone.vue', () => ({ default: 'UserProfile' }));
vi.mock('../Auth.standalone.vue', () => ({ default: 'Auth' }));
vi.mock('../ConnectSettings/ConnectSettings.standalone.vue', () => ({ default: 'ConnectSettings' }));
vi.mock('../DownloadApiLogs.standalone.vue', () => ({ default: 'DownloadApiLogs' }));
vi.mock('@/components/Modals.standalone.vue', () => ({ default: 'Modals' }));
vi.mock('../Registration.standalone.vue', () => ({ default: 'Registration' }));
vi.mock('../WanIpCheck.standalone.vue', () => ({ default: 'WanIpCheck' }));
vi.mock('../CallbackHandler.standalone.vue', () => ({ default: 'CallbackHandler' }));
vi.mock('../Logs/LogViewer.standalone.vue', () => ({ default: 'LogViewer' }));
vi.mock('../SsoButton.standalone.vue', () => ({ default: 'SsoButton' }));
vi.mock('../Activation/WelcomeModal.standalone.vue', () => ({ default: 'WelcomeModal' }));
vi.mock('../UpdateOs.standalone.vue', () => ({ default: 'UpdateOs' }));
vi.mock('../DowngradeOs.standalone.vue', () => ({ default: 'DowngradeOs' }));
vi.mock('../DevSettings.vue', () => ({ default: 'DevSettings' }));
vi.mock('../ApiKeyPage.standalone.vue', () => ({ default: 'ApiKeyPage' }));
vi.mock('../ApiKeyAuthorize.standalone.vue', () => ({ default: 'ApiKeyAuthorize' }));
vi.mock('../DevModalTest.standalone.vue', () => ({ default: 'DevModalTest' }));
vi.mock('../LayoutViews/Detail/DetailTest.standalone.vue', () => ({ default: 'DetailTest' }));
vi.mock('@/components/ThemeSwitcher.standalone.vue', () => ({ default: 'ThemeSwitcher' }));
vi.mock('../ColorSwitcher.standalone.vue', () => ({ default: 'ColorSwitcher' }));
vi.mock('@/components/UnraidToaster.vue', () => ({ default: 'UnraidToaster' }));
vi.mock('../UpdateOs/TestUpdateModal.standalone.vue', () => ({ default: 'TestUpdateModal' }));
vi.mock('../TestThemeSwitcher.standalone.vue', () => ({ default: 'TestThemeSwitcher' }));
describe('component-registry', () => {
it('should export ComponentMapping type', async () => {
const module = await import('~/components/Wrapper/component-registry');
expect(module).toBeDefined();
});
it('should export componentMappings array', async () => {
const { componentMappings } = await import('~/components/Wrapper/component-registry');
expect(Array.isArray(componentMappings)).toBe(true);
expect(componentMappings.length).toBeGreaterThan(0);
});
it('should have required properties for each component mapping', async () => {
const { componentMappings } = await import('~/components/Wrapper/component-registry');
componentMappings.forEach((mapping) => {
expect(mapping).toHaveProperty('selector');
expect(mapping).toHaveProperty('appId');
expect(mapping).toHaveProperty('component');
// Check selector is string or array
expect(typeof mapping.selector === 'string' || Array.isArray(mapping.selector)).toBe(true);
// Check appId is string
expect(typeof mapping.appId).toBe('string');
// Check component exists and is an object
expect(mapping.component).toBeDefined();
expect(typeof mapping.component).toBe('object');
});
});
it('should have priority components listed first', async () => {
const { componentMappings } = await import('~/components/Wrapper/component-registry');
// Priority components should be first
expect(componentMappings[0].appId).toBe('header-os-version');
expect(componentMappings[1].appId).toBe('user-profile');
});
it('should support multiple selectors for modals', async () => {
const { componentMappings } = await import('~/components/Wrapper/component-registry');
const modalsMapping = componentMappings.find((m) => m.appId === 'modals');
expect(Array.isArray(modalsMapping?.selector)).toBe(true);
expect(modalsMapping?.selector).toContain('unraid-modals');
expect(modalsMapping?.selector).toContain('#modals');
expect(modalsMapping?.selector).toContain('modals-direct');
});
it('should support multiple selectors for api key components', async () => {
const { componentMappings } = await import('~/components/Wrapper/component-registry');
const apiKeyMapping = componentMappings.find((m) => m.appId === 'apikey-page');
expect(Array.isArray(apiKeyMapping?.selector)).toBe(true);
expect(apiKeyMapping?.selector).toContain('unraid-apikey-page');
expect(apiKeyMapping?.selector).toContain('unraid-api-key-manager');
});
it('should support multiple selectors for toaster', async () => {
const { componentMappings } = await import('~/components/Wrapper/component-registry');
const toasterMapping = componentMappings.find((m) => m.appId === 'toaster');
expect(Array.isArray(toasterMapping?.selector)).toBe(true);
expect(toasterMapping?.selector).toContain('unraid-toaster');
expect(toasterMapping?.selector).toContain('uui-toaster');
});
it('should have unique appIds', async () => {
const { componentMappings } = await import('~/components/Wrapper/component-registry');
const appIds = componentMappings.map((m) => m.appId);
const uniqueAppIds = new Set(appIds);
expect(appIds.length).toBe(uniqueAppIds.size);
});
it('should define all components as async components', async () => {
const { componentMappings } = await import('~/components/Wrapper/component-registry');
componentMappings.forEach((mapping) => {
expect(mapping.component).toBeDefined();
expect(typeof mapping.component).toBe('object');
});
});
it('should have at least the core component mappings', async () => {
const { componentMappings } = await import('~/components/Wrapper/component-registry');
// Just ensure we have a reasonable number of components, not an exact count
expect(componentMappings.length).toBeGreaterThan(10);
});
it('should include all expected components', async () => {
const { componentMappings } = await import('~/components/Wrapper/component-registry');
const expectedAppIds = [
'header-os-version',
'user-profile',
'auth',
'connect-settings',
'download-api-logs',
'modals',
'registration',
'wan-ip-check',
'callback-handler',
'log-viewer',
'sso-button',
'welcome-modal',
'update-os',
'downgrade-os',
'dev-settings',
'apikey-page',
'apikey-authorize',
'dev-modal-test',
'detail-test',
'theme-switcher',
'color-switcher',
'toaster',
'test-update-modal',
'test-theme-switcher',
];
const actualAppIds = componentMappings.map((m) => m.appId);
expectedAppIds.forEach((appId) => {
expect(actualAppIds).toContain(appId);
});
});
it('should properly format selectors', async () => {
const { componentMappings } = await import('~/components/Wrapper/component-registry');
componentMappings.forEach((mapping) => {
if (typeof mapping.selector === 'string') {
// Single selectors should be non-empty strings
expect(mapping.selector.length).toBeGreaterThan(0);
} else if (Array.isArray(mapping.selector)) {
// Array selectors should have at least one item
expect(mapping.selector.length).toBeGreaterThan(0);
// Each selector in array should be non-empty string
mapping.selector.forEach((sel) => {
expect(typeof sel).toBe('string');
expect(sel.length).toBeGreaterThan(0);
});
}
});
});
});

View File

@@ -2,31 +2,33 @@ import { defineComponent, h } from 'vue';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import type { ComponentMapping } from '~/components/Wrapper/component-registry';
import type { MockInstance } from 'vitest';
import type { App as VueApp } from 'vue';
// Extend HTMLElement to include Vue's internal properties (matching the source file)
interface HTMLElementWithVue extends HTMLElement {
__vueParentComponent?: {
appContext?: {
app?: VueApp;
};
};
}
// We'll manually mock createApp only in specific tests that need it
vi.mock('vue', async () => {
const actual = await vi.importActual<typeof import('vue')>('vue');
return {
...actual,
};
});
const mockEnsureTeleportContainer = vi.fn();
vi.mock('@unraid/ui', () => ({
ensureTeleportContainer: mockEnsureTeleportContainer,
// Mock @nuxt/ui components
vi.mock('@nuxt/ui/components/App.vue', () => ({
default: defineComponent({
name: 'UApp',
setup(_, { slots }) {
return () => h('div', { class: 'u-app' }, slots.default?.());
},
}),
}));
vi.mock('@nuxt/ui/vue-plugin', () => ({
default: {
install: vi.fn(),
},
}));
// Mock component registry
const mockComponentMappings: ComponentMapping[] = [];
vi.mock('~/components/Wrapper/component-registry', () => ({
componentMappings: mockComponentMappings,
}));
// Mock dependencies
const mockI18n = {
global: {},
install: vi.fn(),
@@ -60,26 +62,22 @@ vi.mock('~/helpers/i18n-utils', () => ({
createHtmlEntityDecoder: vi.fn(() => (str: string) => str),
}));
// CSS is now bundled separately by Vite, no inline imports
describe('mount-engine', () => {
let mountVueApp: typeof import('~/components/Wrapper/mount-engine').mountVueApp;
let unmountVueApp: typeof import('~/components/Wrapper/mount-engine').unmountVueApp;
let getMountedApp: typeof import('~/components/Wrapper/mount-engine').getMountedApp;
let autoMountComponent: typeof import('~/components/Wrapper/mount-engine').autoMountComponent;
let mountUnifiedApp: typeof import('~/components/Wrapper/mount-engine').mountUnifiedApp;
let autoMountAllComponents: typeof import('~/components/Wrapper/mount-engine').autoMountAllComponents;
let TestComponent: ReturnType<typeof defineComponent>;
let consoleWarnSpy: MockInstance;
let consoleErrorSpy: MockInstance;
let consoleInfoSpy: MockInstance;
let consoleDebugSpy: MockInstance;
let testContainer: HTMLDivElement;
beforeEach(async () => {
// Clear component mappings
mockComponentMappings.length = 0;
// Import fresh module
vi.resetModules();
const module = await import('~/components/Wrapper/mount-engine');
mountVueApp = module.mountVueApp;
unmountVueApp = module.unmountVueApp;
getMountedApp = module.getMountedApp;
autoMountComponent = module.autoMountComponent;
mountUnifiedApp = module.mountUnifiedApp;
autoMountAllComponents = module.autoMountAllComponents;
TestComponent = defineComponent({
name: 'TestComponent',
@@ -96,526 +94,319 @@ describe('mount-engine', () => {
consoleWarnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
consoleInfoSpy = vi.spyOn(console, 'info').mockImplementation(() => {});
consoleDebugSpy = vi.spyOn(console, 'debug').mockImplementation(() => {});
testContainer = document.createElement('div');
testContainer.id = 'test-container';
document.body.appendChild(testContainer);
vi.clearAllMocks();
// Clear mounted apps from previous tests
if (window.mountedApps) {
window.mountedApps.clear();
}
// Clean up DOM
document.body.innerHTML = '';
});
afterEach(() => {
vi.restoreAllMocks();
document.body.innerHTML = '';
if (window.mountedApps) {
window.mountedApps.clear();
// Clean up global references
if (window.__unifiedApp) {
delete window.__unifiedApp;
}
if (window.__mountedComponents) {
delete window.__mountedComponents;
}
});
describe('mountVueApp', () => {
it('should mount a Vue app to a single element', () => {
describe('mountUnifiedApp', () => {
it('should create and mount a unified app with shared context', async () => {
// Add a component mapping
const element = document.createElement('div');
element.id = 'app';
element.id = 'test-app';
document.body.appendChild(element);
const app = mountVueApp({
mockComponentMappings.push({
selector: '#test-app',
appId: 'test-app',
component: TestComponent,
selector: '#app',
});
const app = mountUnifiedApp();
expect(app).toBeTruthy();
expect(element.querySelector('.test-component')).toBeTruthy();
expect(element.textContent).toBe('Hello');
expect(mockEnsureTeleportContainer).toHaveBeenCalled();
});
expect(mockI18n.install).toHaveBeenCalled();
expect(mockGlobalPinia.install).toHaveBeenCalled();
it('should mount with custom props', () => {
const element = document.createElement('div');
element.id = 'app';
document.body.appendChild(element);
const app = mountVueApp({
component: TestComponent,
selector: '#app',
props: { message: 'Custom Message' },
// Wait for async component to render
await vi.waitFor(() => {
expect(element.querySelector('.test-component')).toBeTruthy();
});
expect(app).toBeTruthy();
expect(element.textContent).toBe('Custom Message');
// Check that component was rendered
expect(element.textContent).toContain('Hello');
expect(element.getAttribute('data-vue-mounted')).toBe('true');
expect(element.classList.contains('unapi')).toBe(true);
});
it('should parse props from element attributes', () => {
it('should parse props from element attributes', async () => {
const element = document.createElement('div');
element.id = 'app';
element.id = 'test-app';
element.setAttribute('message', 'Attribute Message');
document.body.appendChild(element);
const app = mountVueApp({
mockComponentMappings.push({
selector: '#test-app',
appId: 'test-app',
component: TestComponent,
selector: '#app',
});
expect(app).toBeTruthy();
expect(element.textContent).toBe('Attribute Message');
mountUnifiedApp();
// Wait for async component to render
await vi.waitFor(() => {
expect(element.textContent).toContain('Attribute Message');
});
});
it('should parse JSON props from attributes', () => {
it('should handle JSON props from attributes', () => {
const element = document.createElement('div');
element.id = 'app';
element.id = 'test-app';
element.setAttribute('message', '{"text": "JSON Message"}');
document.body.appendChild(element);
const app = mountVueApp({
mockComponentMappings.push({
selector: '#test-app',
appId: 'test-app',
component: TestComponent,
selector: '#app',
});
expect(app).toBeTruthy();
mountUnifiedApp();
// The component receives the parsed JSON object
expect(element.getAttribute('message')).toBe('{"text": "JSON Message"}');
});
it('should handle HTML-encoded JSON in attributes', () => {
const element = document.createElement('div');
element.id = 'app';
element.id = 'test-app';
element.setAttribute('message', '{&quot;text&quot;: &quot;Encoded&quot;}');
document.body.appendChild(element);
const app = mountVueApp({
mockComponentMappings.push({
selector: '#test-app',
appId: 'test-app',
component: TestComponent,
selector: '#app',
});
expect(app).toBeTruthy();
mountUnifiedApp();
expect(element.getAttribute('message')).toBe('{&quot;text&quot;: &quot;Encoded&quot;}');
});
it('should mount to multiple elements', () => {
it('should handle multiple selector aliases', async () => {
const element1 = document.createElement('div');
element1.className = 'multi-mount';
element1.id = 'app1';
document.body.appendChild(element1);
const element2 = document.createElement('div');
element2.className = 'multi-mount';
element2.className = 'app-alt';
document.body.appendChild(element2);
const app = mountVueApp({
// Component with multiple selector aliases - only first match mounts
mockComponentMappings.push({
selector: ['#app1', '.app-alt'],
appId: 'multi-selector',
component: TestComponent,
selector: '.multi-mount',
});
expect(app).toBeTruthy();
expect(element1.querySelector('.test-component')).toBeTruthy();
expect(element2.querySelector('.test-component')).toBeTruthy();
mountUnifiedApp();
// Wait for async component to render
await vi.waitFor(() => {
expect(element1.querySelector('.test-component')).toBeTruthy();
});
// Only the first matching element should be mounted
expect(element1.getAttribute('data-vue-mounted')).toBe('true');
// Second element should not be mounted (first match wins)
expect(element2.querySelector('.test-component')).toBeFalsy();
expect(element2.getAttribute('data-vue-mounted')).toBeNull();
});
it('should use shadow root when specified', () => {
it('should handle async component loaders', async () => {
const element = document.createElement('div');
element.id = 'shadow-app';
element.id = 'async-app';
document.body.appendChild(element);
const app = mountVueApp({
mockComponentMappings.push({
selector: '#async-app',
appId: 'async-app',
component: TestComponent,
selector: '#shadow-app',
useShadowRoot: true,
});
expect(app).toBeTruthy();
expect(element.shadowRoot).toBeTruthy();
expect(element.shadowRoot?.querySelector('#app')).toBeTruthy();
expect(element.shadowRoot?.querySelector('.test-component')).toBeTruthy();
mountUnifiedApp();
// Wait for component to mount
await vi.waitFor(() => {
expect(element.querySelector('.test-component')).toBeTruthy();
});
});
it('should clean up existing Vue attributes', () => {
it('should skip already mounted elements', () => {
const element = document.createElement('div');
element.id = 'app';
element.id = 'already-mounted';
element.setAttribute('data-vue-mounted', 'true');
element.setAttribute('data-v-app', '');
element.setAttribute('data-server-rendered', 'true');
element.setAttribute('data-v-123', '');
document.body.appendChild(element);
mountVueApp({
mockComponentMappings.push({
selector: '#already-mounted',
appId: 'already-mounted',
component: TestComponent,
selector: '#app',
});
expect(consoleWarnSpy).toHaveBeenCalledWith(
'[VueMountApp] Element #app has Vue attributes but no content, cleaning up'
);
mountUnifiedApp();
// Should not mount to already mounted element
expect(element.querySelector('.test-component')).toBeFalsy();
});
it('should handle elements with problematic child nodes', () => {
const element = document.createElement('div');
element.id = 'app';
element.appendChild(document.createTextNode(' '));
element.appendChild(document.createComment('test comment'));
document.body.appendChild(element);
const app = mountVueApp({
component: TestComponent,
selector: '#app',
});
expect(app).toBeTruthy();
expect(consoleWarnSpy).toHaveBeenCalledWith(
'[VueMountApp] Cleaning up problematic nodes in #app before mounting'
);
});
it('should return null when no elements found', () => {
const app = mountVueApp({
component: TestComponent,
it('should handle missing elements gracefully', () => {
mockComponentMappings.push({
selector: '#non-existent',
appId: 'non-existent',
component: TestComponent,
});
expect(app).toBeNull();
expect(consoleWarnSpy).toHaveBeenCalledWith(
'[VueMountApp] No elements found for any selector: #non-existent'
const app = mountUnifiedApp();
// Should still create the app successfully
expect(app).toBeTruthy();
// No errors should be thrown
expect(consoleErrorSpy).not.toHaveBeenCalled();
});
it('should error on invalid component mapping', async () => {
const element = document.createElement('div');
element.id = 'invalid-app';
document.body.appendChild(element);
// Invalid mapping - no component
mockComponentMappings.push({
selector: '#invalid-app',
appId: 'invalid-app',
} as ComponentMapping);
mountUnifiedApp();
// Should log error for missing component
expect(consoleErrorSpy).toHaveBeenCalledWith(
'[UnifiedMount] No component defined for invalid-app'
);
});
it('should add unapi class to mounted elements', () => {
const element = document.createElement('div');
element.id = 'app';
document.body.appendChild(element);
mountVueApp({
component: TestComponent,
selector: '#app',
});
expect(element.classList.contains('unapi')).toBe(true);
expect(element.getAttribute('data-vue-mounted')).toBe('true');
});
it('should skip disconnected elements during multi-mount', () => {
const element1 = document.createElement('div');
element1.className = 'multi';
document.body.appendChild(element1);
const element2 = document.createElement('div');
element2.className = 'multi';
// This element is NOT added to the document
// Create a third element and manually add it to element1 to simulate DOM issues
const orphanedChild = document.createElement('span');
element1.appendChild(orphanedChild);
// Now remove element1 from DOM temporarily to trigger the warning
element1.remove();
// Add element1 back
document.body.appendChild(element1);
// Create elements matching the selector
document.body.innerHTML = '';
const validElement = document.createElement('div');
validElement.className = 'multi';
document.body.appendChild(validElement);
const disconnectedElement = document.createElement('div');
disconnectedElement.className = 'multi';
const container = document.createElement('div');
container.appendChild(disconnectedElement);
// Now disconnectedElement has a parent but that parent is not in the document
const app = mountVueApp({
component: TestComponent,
selector: '.multi',
});
expect(app).toBeTruthy();
// The app should mount only to the connected element
expect(validElement.querySelector('.test-component')).toBeTruthy();
});
});
describe('unmountVueApp', () => {
it('should unmount a mounted app', () => {
const element = document.createElement('div');
element.id = 'app';
document.body.appendChild(element);
const app = mountVueApp({
component: TestComponent,
selector: '#app',
appId: 'test-app',
});
expect(app).toBeTruthy();
expect(getMountedApp('test-app')).toBe(app);
const result = unmountVueApp('test-app');
expect(result).toBe(true);
expect(getMountedApp('test-app')).toBeUndefined();
});
it('should clean up data attributes on unmount', () => {
const element = document.createElement('div');
element.id = 'app';
document.body.appendChild(element);
mountVueApp({
component: TestComponent,
selector: '#app',
appId: 'test-app',
});
expect(element.getAttribute('data-vue-mounted')).toBe('true');
expect(element.classList.contains('unapi')).toBe(true);
unmountVueApp('test-app');
// Component should not be rendered without a valid component
expect(element.querySelector('.test-component')).toBeFalsy();
expect(element.getAttribute('data-vue-mounted')).toBeNull();
});
it('should unmount cloned apps', () => {
it('should create hidden root element if not exists', () => {
mountUnifiedApp();
const rootElement = document.getElementById('unraid-unified-root');
expect(rootElement).toBeTruthy();
expect(rootElement?.style.display).toBe('none');
});
it('should reuse existing root element', () => {
// Create root element first
const existingRoot = document.createElement('div');
existingRoot.id = 'unraid-unified-root';
document.body.appendChild(existingRoot);
mountUnifiedApp();
const rootElement = document.getElementById('unraid-unified-root');
expect(rootElement).toBe(existingRoot);
});
it('should wrap components in UApp for Nuxt UI support', async () => {
const element = document.createElement('div');
element.id = 'wrapped-app';
document.body.appendChild(element);
mockComponentMappings.push({
selector: '#wrapped-app',
appId: 'wrapped-app',
component: TestComponent,
});
mountUnifiedApp();
// Wait for async component to render
await vi.waitFor(() => {
expect(element.querySelector('.u-app')).toBeTruthy();
});
// Check that UApp wrapper is present
expect(element.querySelector('.u-app .test-component')).toBeTruthy();
});
it('should share app context across all components', async () => {
const element1 = document.createElement('div');
element1.className = 'multi';
element1.id = 'app1';
document.body.appendChild(element1);
const element2 = document.createElement('div');
element2.className = 'multi';
element2.id = 'app2';
document.body.appendChild(element2);
mountVueApp({
component: TestComponent,
selector: '.multi',
appId: 'multi-app',
});
const result = unmountVueApp('multi-app');
expect(result).toBe(true);
});
it('should remove shadow root containers', () => {
const element = document.createElement('div');
element.id = 'shadow-app';
document.body.appendChild(element);
mountVueApp({
component: TestComponent,
selector: '#shadow-app',
appId: 'shadow-app',
useShadowRoot: true,
});
expect(element.shadowRoot?.querySelector('#app')).toBeTruthy();
unmountVueApp('shadow-app');
expect(element.shadowRoot?.querySelector('#app')).toBeFalsy();
});
it('should warn when unmounting non-existent app', () => {
const result = unmountVueApp('non-existent');
expect(result).toBe(false);
expect(consoleWarnSpy).toHaveBeenCalledWith('[VueMountApp] No app found with id: non-existent');
});
it('should handle unmount errors gracefully', () => {
const element = document.createElement('div');
element.id = 'app';
document.body.appendChild(element);
const app = mountVueApp({
component: TestComponent,
selector: '#app',
appId: 'test-app',
});
// Force an error by corrupting the app
if (app) {
(app as { unmount: () => void }).unmount = () => {
throw new Error('Unmount error');
};
}
const result = unmountVueApp('test-app');
expect(result).toBe(true);
expect(consoleWarnSpy).toHaveBeenCalledWith(
'[VueMountApp] Error unmounting app test-app:',
expect.any(Error)
mockComponentMappings.push(
{
selector: '#app1',
appId: 'app1',
component: TestComponent,
},
{
selector: '#app2',
appId: 'app2',
component: TestComponent,
}
);
});
});
describe('getMountedApp', () => {
it('should return mounted app by id', () => {
const element = document.createElement('div');
element.id = 'app';
document.body.appendChild(element);
mountUnifiedApp();
const app = mountVueApp({
component: TestComponent,
selector: '#app',
appId: 'test-app',
// Wait for async components to render
await vi.waitFor(() => {
expect(element1.querySelector('.test-component')).toBeTruthy();
expect(element2.querySelector('.test-component')).toBeTruthy();
});
expect(getMountedApp('test-app')).toBe(app);
});
it('should return undefined for non-existent app', () => {
expect(getMountedApp('non-existent')).toBeUndefined();
// Only one Pinia instance should be installed
expect(mockGlobalPinia.install).toHaveBeenCalledTimes(1);
// Only one i18n instance should be installed
expect(mockI18n.install).toHaveBeenCalledTimes(1);
});
});
describe('autoMountComponent', () => {
beforeEach(() => {
vi.useFakeTimers();
});
afterEach(() => {
vi.useRealTimers();
});
it('should auto-mount when DOM is ready', async () => {
describe('autoMountAllComponents', () => {
it('should call mountUnifiedApp', async () => {
const element = document.createElement('div');
element.id = 'auto-app';
document.body.appendChild(element);
autoMountComponent(TestComponent, '#auto-app');
await vi.runAllTimersAsync();
expect(element.querySelector('.test-component')).toBeTruthy();
});
it('should wait for DOMContentLoaded if document is loading', async () => {
Object.defineProperty(document, 'readyState', {
value: 'loading',
writable: true,
});
const element = document.createElement('div');
element.id = 'auto-app';
document.body.appendChild(element);
autoMountComponent(TestComponent, '#auto-app');
expect(element.querySelector('.test-component')).toBeFalsy();
Object.defineProperty(document, 'readyState', {
value: 'complete',
writable: true,
});
document.dispatchEvent(new Event('DOMContentLoaded'));
await vi.runAllTimersAsync();
expect(element.querySelector('.test-component')).toBeTruthy();
});
it('should skip auto-mount for already mounted modals', async () => {
const element1 = document.createElement('div');
element1.id = 'modals';
document.body.appendChild(element1);
mountVueApp({
mockComponentMappings.push({
selector: '#auto-app',
appId: 'auto-app',
component: TestComponent,
selector: '#modals',
appId: 'modals',
});
autoMountComponent(TestComponent, '#modals');
await vi.runAllTimersAsync();
autoMountAllComponents();
expect(consoleDebugSpy).toHaveBeenCalledWith(
'[VueMountApp] Component already mounted as modals for selector #modals, returning existing instance'
);
});
it('should mount immediately for all selectors', async () => {
const element = document.createElement('div');
element.id = 'unraid-connect-settings';
document.body.appendChild(element);
autoMountComponent(TestComponent, '#unraid-connect-settings');
// Component should mount immediately without delay
await vi.runAllTimersAsync();
expect(element.querySelector('.test-component')).toBeTruthy();
});
it('should mount even when element is hidden', async () => {
const element = document.createElement('div');
element.id = 'hidden-app';
element.style.display = 'none';
document.body.appendChild(element);
autoMountComponent(TestComponent, '#hidden-app');
await vi.runAllTimersAsync();
// Hidden elements should still mount successfully
expect(element.querySelector('.test-component')).toBeTruthy();
expect(consoleWarnSpy).not.toHaveBeenCalledWith(
expect.stringContaining('No valid DOM elements found')
);
});
it('should handle nextSibling errors with retry', async () => {
const element = document.createElement('div');
element.id = 'error-app';
element.setAttribute('data-vue-mounted', 'true');
element.setAttribute('data-v-app', '');
document.body.appendChild(element);
// Simulate the element having Vue instance references which cause nextSibling errors
const mockVueInstance = { appContext: { app: {} as VueApp } };
(element as HTMLElementWithVue).__vueParentComponent = mockVueInstance;
// Add an invalid child that will trigger cleanup
const textNode = document.createTextNode(' ');
element.appendChild(textNode);
autoMountComponent(TestComponent, '#error-app');
await vi.runAllTimersAsync();
// Should detect and clean up existing Vue state
expect(consoleWarnSpy).toHaveBeenCalledWith(
expect.stringContaining(
'[VueMountApp] Element #error-app has Vue attributes but no content, cleaning up'
)
);
// Should successfully mount after cleanup
expect(element.querySelector('.test-component')).toBeTruthy();
});
it('should pass options to mountVueApp', async () => {
const element = document.createElement('div');
element.id = 'options-app';
document.body.appendChild(element);
autoMountComponent(TestComponent, '#options-app', {
props: { message: 'Auto Mount Message' },
useShadowRoot: true,
// Wait for async component to render
await vi.waitFor(() => {
expect(element.querySelector('.test-component')).toBeTruthy();
});
await vi.runAllTimersAsync();
expect(element.shadowRoot).toBeTruthy();
expect(element.shadowRoot?.textContent).toContain('Auto Mount Message');
});
});
describe('i18n setup', () => {
it('should setup i18n with default locale', () => {
const element = document.createElement('div');
element.id = 'i18n-app';
document.body.appendChild(element);
mountVueApp({
component: TestComponent,
selector: '#i18n-app',
});
mountUnifiedApp();
expect(mockI18n.install).toHaveBeenCalled();
});
@@ -627,14 +418,7 @@ describe('mount-engine', () => {
JSON.stringify(localeData)
);
const element = document.createElement('div');
element.id = 'i18n-app';
document.body.appendChild(element);
mountVueApp({
component: TestComponent,
selector: '#i18n-app',
});
mountUnifiedApp();
delete (window as unknown as Record<string, unknown>).LOCALE_DATA;
});
@@ -642,14 +426,7 @@ describe('mount-engine', () => {
it('should handle locale data parsing errors', () => {
(window as unknown as Record<string, unknown>).LOCALE_DATA = 'invalid json';
const element = document.createElement('div');
element.id = 'i18n-app';
document.body.appendChild(element);
mountVueApp({
component: TestComponent,
selector: '#i18n-app',
});
mountUnifiedApp();
expect(consoleErrorSpy).toHaveBeenCalledWith(
'[VueMountApp] error parsing messages',
@@ -660,60 +437,28 @@ describe('mount-engine', () => {
});
});
describe('error recovery', () => {
it('should attempt recovery from nextSibling error', async () => {
vi.useFakeTimers();
describe('global exposure', () => {
it('should expose unified app globally', () => {
const app = mountUnifiedApp();
expect(window.__unifiedApp).toBe(app);
});
it('should expose mounted components globally', () => {
const element = document.createElement('div');
element.id = 'recovery-app';
element.id = 'global-app';
document.body.appendChild(element);
// Create a mock Vue app that throws on first mount attempt
let mountAttempt = 0;
const mockApp = {
use: vi.fn().mockReturnThis(),
provide: vi.fn().mockReturnThis(),
mount: vi.fn().mockImplementation(() => {
mountAttempt++;
if (mountAttempt === 1) {
const error = new TypeError('Cannot read property nextSibling of null');
throw error;
}
return mockApp;
}),
unmount: vi.fn(),
version: '3.0.0',
config: { globalProperties: {} },
};
// Mock createApp using module mock
const vueModule = await import('vue');
vi.spyOn(vueModule, 'createApp').mockReturnValue(mockApp as never);
mountVueApp({
mockComponentMappings.push({
selector: '#global-app',
appId: 'global-app',
component: TestComponent,
selector: '#recovery-app',
appId: 'recovery-app',
});
expect(consoleWarnSpy).toHaveBeenCalledWith(
'[VueMountApp] Attempting recovery from nextSibling error for #recovery-app'
);
mountUnifiedApp();
await vi.advanceTimersByTimeAsync(10);
expect(consoleInfoSpy).toHaveBeenCalledWith(
'[VueMountApp] Successfully recovered from nextSibling error for #recovery-app'
);
vi.useRealTimers();
});
});
describe('global exposure', () => {
it('should expose mountedApps globally', () => {
expect(window.mountedApps).toBeDefined();
expect(window.mountedApps).toBeInstanceOf(Map);
expect(window.__mountedComponents).toBeDefined();
expect(Array.isArray(window.__mountedComponents)).toBe(true);
expect(window.__mountedComponents!.length).toBe(1);
});
it('should expose globalPinia globally', () => {
@@ -721,4 +466,23 @@ describe('mount-engine', () => {
expect(window.globalPinia).toBe(mockGlobalPinia);
});
});
describe('performance debugging', () => {
it('should not log timing by default', () => {
const element = document.createElement('div');
element.id = 'perf-app';
document.body.appendChild(element);
mockComponentMappings.push({
selector: '#perf-app',
appId: 'perf-app',
component: TestComponent,
});
mountUnifiedApp();
// Should not log timing information when PERF_DEBUG is false
expect(consoleWarnSpy).not.toHaveBeenCalledWith(expect.stringContaining('[UnifiedMount] Mounted'));
});
});
});

View File

@@ -57,16 +57,12 @@ vi.mock('~/components/UnraidToaster.vue', () => ({
}));
// Mock mount-engine module
const mockAutoMountComponent = vi.fn();
const mockAutoMountAllComponents = vi.fn();
const mockMountVueApp = vi.fn();
const mockGetMountedApp = vi.fn();
const mockMountUnifiedApp = vi.fn();
vi.mock('~/components/Wrapper/mount-engine', () => ({
autoMountComponent: mockAutoMountComponent,
autoMountAllComponents: mockAutoMountAllComponents,
mountVueApp: mockMountVueApp,
getMountedApp: mockGetMountedApp,
mountUnifiedApp: mockMountUnifiedApp,
}));
// Mock theme initializer
@@ -104,10 +100,7 @@ vi.mock('graphql', () => ({
}));
// Mock @unraid/ui
const mockEnsureTeleportContainer = vi.fn();
vi.mock('@unraid/ui', () => ({
ensureTeleportContainer: mockEnsureTeleportContainer,
}));
vi.mock('@unraid/ui', () => ({}));
describe('component-registry', () => {
beforeEach(() => {
@@ -151,10 +144,12 @@ describe('component-registry', () => {
expect(mockInitializeTheme).toHaveBeenCalled();
});
it('should ensure teleport container exists', async () => {
it('should mount unified app with components', async () => {
await import('~/components/Wrapper/auto-mount');
expect(mockEnsureTeleportContainer).toHaveBeenCalled();
// The unified app architecture no longer requires teleport container setup per component
// Instead it uses a unified approach
expect(mockAutoMountAllComponents).toHaveBeenCalled();
});
});
@@ -188,26 +183,29 @@ describe('component-registry', () => {
it('should expose utility functions globally', async () => {
await import('~/components/Wrapper/auto-mount');
expect(window.mountVueApp).toBe(mockMountVueApp);
expect(window.getMountedApp).toBe(mockGetMountedApp);
expect(window.autoMountComponent).toBe(mockAutoMountComponent);
// With unified app architecture, these are exposed instead:
expect(window.apolloClient).toBe(mockApolloClient);
expect(window.gql).toBe(mockParse);
expect(window.graphqlParse).toBe(mockParse);
// The unified app itself is exposed via window.__unifiedApp after mounting
});
it('should expose mountVueApp function globally', async () => {
it('should not expose legacy mount functions', async () => {
await import('~/components/Wrapper/auto-mount');
// Check that mountVueApp is exposed
expect(typeof window.mountVueApp).toBe('function');
// Note: Dynamic mount functions are no longer created automatically
// They would be created via mountVueApp calls
// These functions are no longer exposed in the unified app architecture
expect(window.mountVueApp).toBeUndefined();
expect(window.getMountedApp).toBeUndefined();
expect(window.autoMountComponent).toBeUndefined();
});
it('should expose autoMountComponent function globally', async () => {
it('should expose apollo client and graphql utilities', async () => {
await import('~/components/Wrapper/auto-mount');
// Check that autoMountComponent is exposed
expect(typeof window.autoMountComponent).toBe('function');
// Check that Apollo client and GraphQL utilities are exposed
expect(window.apolloClient).toBeDefined();
expect(typeof window.gql).toBe('function');
expect(typeof window.graphqlParse).toBe('function');
});
});
});

View File

@@ -103,6 +103,7 @@ export default [
parser: tseslint.parser,
parserOptions: {
...commonLanguageOptions,
tsconfigRootDir: import.meta.dirname,
ecmaFeatures: {
jsx: true,
},
@@ -128,6 +129,7 @@ export default [
parserOptions: {
...commonLanguageOptions,
parser: tseslint.parser,
tsconfigRootDir: import.meta.dirname,
ecmaFeatures: {
jsx: true,
},

View File

@@ -38,9 +38,9 @@
},
"devDependencies": {
"@eslint/js": "9.34.0",
"@graphql-codegen/cli": "5.0.7",
"@graphql-codegen/client-preset": "4.8.3",
"@graphql-codegen/introspection": "4.0.3",
"@graphql-codegen/cli": "6.0.0",
"@graphql-codegen/client-preset": "5.0.0",
"@graphql-codegen/introspection": "5.0.0",
"@graphql-typed-document-node/core": "3.2.0",
"@ianvs/prettier-plugin-sort-imports": "4.6.3",
"@pinia/testing": "1.0.2",

View File

@@ -3,12 +3,14 @@
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Standalone Vue Apps Test Page</title>
<title>Component Mounting Test - Unraid Component Test</title>
<script src="https://code.jquery.com/jquery-3.7.1.min.js"></script>
<style>
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, sans-serif;
font-family: system-ui, -apple-system, sans-serif;
margin: 0;
padding: 20px;
background: #f5f5f5;
background: #f3f4f6;
}
.container {
max-width: 1200px;
@@ -171,12 +173,11 @@
</div>
</div>
<!-- Mock configurations for local testing -->
<!-- Configuration for local testing -->
<script>
// Set GraphQL endpoint directly to API server
// Change this to match your API server port
window.GRAPHQL_ENDPOINT = 'http://localhost:3001/graphql';
// Set GraphQL endpoint - handled by Vite proxy in dev mode
window.GRAPHQL_ENDPOINT = window.location.port === '3000' ? '/graphql' : 'http://localhost:3001/graphql';
// Mock webGui path for images
window.__WEBGUI_PATH__ = '';
@@ -203,29 +204,30 @@
// Check for Vue app mounting
let checkInterval = setInterval(() => {
const mountedElements = document.querySelectorAll('unraid-header-os-version');
let mountedCount = 0;
mountedElements.forEach(el => {
if (el.innerHTML.trim() !== '') {
mountedCount++;
}
});
const mountedElements = document.querySelectorAll('[data-vue-mounted="true"]');
let totalComponents = document.querySelectorAll('unraid-header-os-version, unraid-modals').length;
let mountedCount = mountedElements.length;
if (mountedCount > 0) {
status.className = 'status success';
status.textContent = `✅ Successfully mounted ${mountedCount} component(s)`;
// Update debug info
debugInfo.textContent = `
Components Found: ${mountedElements.length}
Components Found: ${totalComponents}
Components Mounted: ${mountedCount}
Vue Apps: ${window.mountedApps ? Object.keys(window.mountedApps).length : 0}
Unified Vue App: ${window.__unifiedApp ? 'Initialized' : 'Not found'}
Mounted Components: ${window.__mountedComponents ? window.__mountedComponents.length : 0}
Pinia Store: ${window.globalPinia ? 'Initialized' : 'Not found'}
GraphQL Endpoint: ${window.GRAPHQL_ENDPOINT || 'Not configured'}
`.trim();
clearInterval(checkInterval);
// Log to test console if available
if (window.testLog) {
window.testLog(`Mounted ${mountedCount} components successfully`, 'success');
}
}
}, 500);
@@ -245,37 +247,53 @@ GraphQL Endpoint: ${window.GRAPHQL_ENDPOINT || 'Not configured'}
document.addEventListener('DOMContentLoaded', () => {
let dynamicCount = 0;
const dynamicContainer = document.getElementById('dynamicContainer');
document.getElementById('addComponent').addEventListener('click', () => {
dynamicCount++;
const wrapper = document.createElement('div');
wrapper.className = 'mount-target';
wrapper.setAttribute('data-label', `Dynamic Instance ${dynamicCount}`);
wrapper.style.marginBottom = '10px';
wrapper.innerHTML = '<unraid-header-os-version></unraid-header-os-version>';
// Create the custom element
const element = document.createElement('unraid-header-os-version');
wrapper.appendChild(element);
dynamicContainer.appendChild(wrapper);
// Trigger mount if app is already loaded
if (window.mountVueApp) {
window.mountVueApp({
component: window.HeaderOsVersion,
selector: 'unraid-header-os-version',
appId: `dynamic-${dynamicCount}`,
});
// The unified mount system doesn't support dynamic addition after initial mount
// For now, we'll just add the element and note it won't be mounted until reload
console.log('Note: Dynamic components require page reload to mount with the unified app system');
// Show a message that reload is needed
if (!wrapper.querySelector('.reload-note')) {
const note = document.createElement('div');
note.className = 'reload-note';
note.style.cssText = 'color: #666; font-size: 12px; margin-top: 10px;';
note.textContent = 'Reload page to mount this component';
wrapper.appendChild(note);
}
});
document.getElementById('removeComponent').addEventListener('click', () => {
const lastChild = dynamicContainer.lastElementChild;
if (lastChild) {
// If component was mounted, unmount it properly
const mountedElement = lastChild.querySelector('[data-vue-mounted="true"]');
if (mountedElement && window.__mountedComponents) {
const componentIndex = window.__mountedComponents.findIndex(c => c.element === mountedElement);
if (componentIndex !== -1) {
window.__mountedComponents[componentIndex].unmount();
window.__mountedComponents.splice(componentIndex, 1);
}
}
dynamicContainer.removeChild(lastChild);
dynamicCount = Math.max(0, dynamicCount - 1);
}
});
document.getElementById('remountAll').addEventListener('click', () => {
// This would require the mount function to be exposed globally
console.log('Remounting all components...');
// The unified app requires a full reload to remount
location.reload();
});
});
@@ -321,7 +339,18 @@ GraphQL Endpoint: ${window.GRAPHQL_ENDPOINT || 'Not configured'}
};
</script>
<!-- Load the standalone app -->
<script type="module" src=".nuxt/standalone-apps/standalone-apps.js"></script>
<!-- Load shared header and manifest resources -->
<script src="/test-pages/shared-header.js"></script>
<script src="/test-pages/load-manifest.js"></script>
<script src="/test-pages/test-server-state.js"></script>
<script>
// Initialize page
document.addEventListener('DOMContentLoaded', () => {
if (window.initializeSharedHeader) {
window.initializeSharedHeader('Component Mounting Test');
}
});
</script>
</body>
</html>

View File

@@ -185,7 +185,15 @@
<h3>Update Modal Testing <span class="badge new">NEW</span></h3>
<p>Test various update scenarios including expired licenses, renewals, and auth requirements</p>
</div>
<a href="/test-update-modal.html">Open →</a>
<a href="/test-pages/update-modal.html">Open →</a>
</div>
<div class="page-item">
<div>
<h3>Component Mounting Test</h3>
<p>Test single and multiple component mounting with shared Pinia store and dynamic creation</p>
</div>
<a href="/test-pages/component-mounting.html">Open →</a>
</div>
</div>
</div>

View File

@@ -40,7 +40,7 @@ if [ "$has_standalone" = true ]; then
rsync -avz --delete -e "ssh" "$standalone_directory" "root@${server_name}:/usr/local/emhttp/plugins/dynamix.my.servers/unraid-components/standalone/"
standalone_exit_code=$?
# If standalone rsync failed, update exit_code
if [ $standalone_exit_code -ne 0 ]; then
if [ "$standalone_exit_code" -ne 0 ]; then
exit_code=$standalone_exit_code
fi
fi
@@ -49,7 +49,9 @@ fi
update_auth_request() {
local server_name="$1"
# SSH into server and update auth-request.php
ssh "root@${server_name}" bash -s << 'EOF'
ssh "root@${server_name}" /bin/bash -s << 'EOF'
set -euo pipefail
set -o errtrace
AUTH_REQUEST_FILE='/usr/local/emhttp/auth-request.php'
UNRAID_COMPS_DIR='/usr/local/emhttp/plugins/dynamix.my.servers/unraid-components/'
@@ -76,8 +78,9 @@ update_auth_request() {
# Clean up any existing temp file
rm -f "$TEMP_FILE"
# Process the file through both stages using a pipeline
# Process the file through both stages
# First remove existing web component entries, then add new ones
# Use a simpler approach without relying on PIPESTATUS
awk '
BEGIN { in_array = 0 }
/\$arrWhitelist\s*=\s*\[/ {
@@ -93,19 +96,40 @@ update_auth_request() {
!in_array || !/\/plugins\/dynamix\.my\.servers\/unraid-components\/.*\.(m?js|css)/ {
print $0
}
' "$AUTH_REQUEST_FILE" | \
' "$AUTH_REQUEST_FILE" > "$TEMP_FILE.stage1" || {
echo "Failed to process $AUTH_REQUEST_FILE (stage 1)" >&2
rm -f "$TEMP_FILE.stage1"
exit 1
}
awk -v files_to_add="$(printf '%s\n' "${FILES_TO_ADD[@]}" | sed "s/'/\\\\'/g" | sort -u | awk '{printf " \047%s\047,\n", $0}')" '
/\$arrWhitelist\s*=\s*\[/ {
/\$arrWhitelist[[:space:]]*=[[:space:]]*\[/ {
print $0
print files_to_add
next
}
{ print }
' > "$TEMP_FILE"
# Check pipeline succeeded and temp file is non-empty
if [ ${PIPESTATUS[0]} -ne 0 ] || [ ${PIPESTATUS[1]} -ne 0 ] || [ ! -s "$TEMP_FILE" ]; then
echo "Failed to process $AUTH_REQUEST_FILE" >&2
' "$TEMP_FILE.stage1" > "$TEMP_FILE" || {
echo "Failed to process $AUTH_REQUEST_FILE (stage 2)" >&2
rm -f "$TEMP_FILE.stage1" "$TEMP_FILE"
exit 1
}
# Clean up intermediate file
rm -f "$TEMP_FILE.stage1"
# Verify whitelist entries were actually injected
if [ ${#FILES_TO_ADD[@]} -gt 0 ]; then
if ! grep -qF "${FILES_TO_ADD[0]}" "$TEMP_FILE"; then
echo "Failed to inject whitelist entries" >&2
rm -f "$TEMP_FILE"
exit 1
fi
fi
# Check temp file is non-empty
if [ ! -s "$TEMP_FILE" ]; then
echo "Failed to process $AUTH_REQUEST_FILE - empty result" >&2
rm -f "$TEMP_FILE"
exit 1
fi
@@ -136,7 +160,7 @@ update_auth_request "$server_name"
auth_request_exit_code=$?
# If auth request update failed, update exit_code
if [ $auth_request_exit_code -ne 0 ]; then
if [ "$auth_request_exit_code" -ne 0 ]; then
exit_code=$auth_request_exit_code
fi

View File

@@ -87,6 +87,7 @@
.unapi p {
margin: 0;
padding: 0;
text-align: unset;
}
/* Reset UL styles to prevent default browser styling */
@@ -143,6 +144,13 @@
/* Note: Tailwind utilities will apply globally but should be used with .unapi prefix in HTML */
/* Ensure unraid-modals container has extremely high z-index */
unraid-modals.unapi {
position: relative;
z-index: 999999;
isolation: isolate;
}
/* Style for Unraid progress frame */
iframe#progressFrame {
background-color: var(--background-color);

View File

@@ -1,8 +1,8 @@
<script lang="ts" setup>
import { computed, onMounted, ref } from 'vue';
import { computed, nextTick, onMounted, ref } from 'vue';
import { useI18n } from 'vue-i18n';
import { storeToRefs } from 'pinia';
import { useQuery } from '@vue/apollo-composable';
import { useLazyQuery } from '@vue/apollo-composable';
import {
ArrowTopRightOnSquareIcon,
@@ -34,11 +34,15 @@ import { useUpdateOsActionsStore } from '~/store/updateOsActions';
const { t } = useI18n();
const { copyWithNotification } = useClipboardWithToast();
// Defer logo cleanup to avoid blocking mount
onMounted(() => {
const logoWrapper = document.querySelector('.logo');
logoWrapper?.classList.remove('logo');
nextTick(() => {
const logoWrapper = document.querySelector('.logo');
logoWrapper?.classList.remove('logo');
});
});
// Initialize all stores - they're needed for the UI
const serverStore = useServerStore();
const updateOsStore = useUpdateOsStore();
const updateOsActionsStore = useUpdateOsActionsStore();
@@ -48,10 +52,19 @@ const { osVersion, rebootType, stateDataError } = storeToRefs(serverStore);
const { available, availableWithRenewal } = storeToRefs(updateOsStore);
const { rebootTypeText } = storeToRefs(updateOsActionsStore);
// Query for version information
const { result: versionsResult } = useQuery(INFO_VERSIONS_QUERY, null, {
fetchPolicy: 'cache-first',
});
// Use lazy query and only load when dropdown is opened
const { load: loadVersions, result: versionsResult } = useLazyQuery(INFO_VERSIONS_QUERY);
// Track if we've loaded the versions yet
const hasLoadedVersions = ref(false);
// Load version data only when dropdown is opened
const handleDropdownOpen = (open: boolean) => {
if (open && !hasLoadedVersions.value) {
hasLoadedVersions.value = true;
loadVersions();
}
};
// Use versions endpoint as primary source, fallback to store
const displayOsVersion = computed(
@@ -174,7 +187,7 @@ const updateOsStatus = computed(() => {
</a>
<div class="mt-2 flex flex-wrap justify-start gap-2">
<DropdownMenuRoot>
<DropdownMenuRoot @update:open="handleDropdownOpen">
<DropdownMenuTrigger as-child>
<Button
variant="link"

View File

@@ -26,7 +26,7 @@ const changelogModalVisible = computed(() => updateOsStore.changelogModalVisible
</script>
<template>
<div id="modals" ref="modals" class="relative z-99999">
<div id="modals" ref="modals" class="relative z-[999999]">
<UpcCallbackFeedback :t="t" :open="callbackStatus !== 'ready'" />
<UpcTrial :t="t" :open="trialModalVisible" />
<UpdateOsCheckUpdateResponseModal :t="t" :open="updateOsModalVisible" />

View File

@@ -333,19 +333,22 @@ const showUpdateEligibility = computed(() => {
</div>
<template v-if="updateOsStatus === 'confirming' && !stateDataError">
<div class="my-4 flex flex-col gap-y-2 text-center">
<div class="my-4 flex flex-col gap-y-2">
<div class="flex flex-col gap-y-1">
<p class="text-lg">
<p class="text-center text-lg">
{{ t('Current Version: Unraid {0}', [osVersion]) }}
</p>
<ChevronDoubleDownIcon class="mx-auto h-8 w-8 animate-pulse fill-current opacity-50" />
<p class="text-lg">
<p class="text-center text-lg">
{{ t('New Version: {0}', [callbackUpdateRelease?.name]) }}
</p>
<p v-if="!callbackUpdateRelease?.version?.includes('+')" class="text-sm italic opacity-75">
<p
v-if="!callbackUpdateRelease?.version?.includes('+')"
class="text-center text-sm italic opacity-75"
>
{{
callbackTypeDowngrade
? t('This downgrade will require a reboot')

View File

@@ -13,14 +13,14 @@ import UpcUptimeExpire from '~/components/UserProfile/UptimeExpire.vue';
cn(
'text-header-text-secondary leading-tight font-semibold',
'flex flex-col items-end justify-end gap-y-0.5',
'xs:flex-row xs:items-baseline xs:gap-x-2 xs:gap-y-0',
'xs:!flex-row xs:items-baseline xs:gap-x-2 xs:gap-y-0',
'text-xs',
$attrs.class as ClassValue
)
"
>
<UpcUptimeExpire :as="'span'" :short-text="true" class="text-xs" />
<span class="xs:inline hidden">&bull;</span>
<span class="xs:!inline hidden">&bull;</span>
<UpcServerState class="text-xs" />
</div>
</template>

View File

@@ -3,13 +3,7 @@
import { provideApolloClient } from '@vue/apollo-composable';
import { ensureTeleportContainer } from '@unraid/ui';
import {
autoMountAllComponents,
autoMountComponent,
getMountedApp,
mountVueApp,
} from '@/components/Wrapper/mount-engine';
import { autoMountAllComponents } from '@/components/Wrapper/mount-engine';
import { client as apolloClient } from '~/helpers/create-apollo-client';
import { parse } from 'graphql';
@@ -22,10 +16,6 @@ function initializeGlobalDependencies() {
// Provide Apollo client globally for all components
provideApolloClient(apolloClient);
// Pre-create the teleport container to avoid mounting issues
// This ensures the container exists before any components try to teleport to it
ensureTeleportContainer();
// Initialize theme once per page load
// This loads theme from GraphQL and applies Tailwind v4 classes
initializeTheme().catch((error: unknown) => {
@@ -33,9 +23,8 @@ function initializeGlobalDependencies() {
});
// Expose utility functions on window for debugging/external use
window.mountVueApp = mountVueApp;
window.getMountedApp = getMountedApp;
window.autoMountComponent = autoMountComponent;
// With unified app, these are no longer needed
// Access the unified app via window.__unifiedApp instead
// Expose Apollo client on window for global access
window.apolloClient = apolloClient;

View File

@@ -2,154 +2,142 @@
// This module defines all web components and their mappings
// Actual mounting is handled by mount-engine.ts
import type { Component } from 'vue';
// Import CSS for bundling - this ensures Tailwind styles are included
import '~/assets/main.css';
// Import @unraid/ui styles which includes vue-sonner styles
import '@unraid/ui/styles';
// Static imports for critical components that are always present
// These are included in the main bundle for faster initial render
import HeaderOsVersionCe from '@/components/HeaderOsVersion.standalone.vue';
import ModalsCe from '@/components/Modals.standalone.vue';
import ThemeSwitcherCe from '@/components/ThemeSwitcher.standalone.vue';
import UnraidToaster from '@/components/UnraidToaster.vue';
import UserProfileCe from '@/components/UserProfile.standalone.vue';
import { defineAsyncComponent } from 'vue';
// Type for Vue component module
type VueComponentModule = { default: object } | object;
import type { Component } from 'vue';
// Type for component mappings
export type ComponentMapping = {
selector: string | string[]; // Can be a single selector or array of selector aliases
appId: string;
} & (
| { component: Component } // Static import
| { loader: () => Promise<VueComponentModule> } // Dynamic import
);
component: Component; // The async component
};
// Define component mappings
// Critical components use static imports (already loaded)
// Page-specific components use dynamic imports (lazy loaded)
// Define component mappings - all components use async loading for consistency
// Priority components (header, user profile) are listed first for faster mounting
export const componentMappings: ComponentMapping[] = [
{
loader: () => import('../Auth.standalone.vue'),
selector: 'unraid-auth',
appId: 'auth',
},
{
loader: () => import('../ConnectSettings/ConnectSettings.standalone.vue'),
selector: 'unraid-connect-settings',
appId: 'connect-settings',
},
{
loader: () => import('../DownloadApiLogs.standalone.vue'),
selector: 'unraid-download-api-logs',
appId: 'download-api-logs',
},
{
component: HeaderOsVersionCe, // Static import - always present in header
component: defineAsyncComponent(() => import('@/components/HeaderOsVersion.standalone.vue')),
selector: 'unraid-header-os-version',
appId: 'header-os-version',
},
{
component: ModalsCe, // Static import - global modals
selector: ['unraid-modals', '#modals', 'modals-direct'], // All possible modal selectors
appId: 'modals',
},
{
component: UserProfileCe, // Static import - always present in header
component: defineAsyncComponent(() => import('@/components/UserProfile.standalone.vue')),
selector: 'unraid-user-profile',
appId: 'user-profile',
},
{
loader: () => import('../Registration.standalone.vue'),
component: defineAsyncComponent(() => import('../Auth.standalone.vue')),
selector: 'unraid-auth',
appId: 'auth',
},
{
component: defineAsyncComponent(() => import('../ConnectSettings/ConnectSettings.standalone.vue')),
selector: 'unraid-connect-settings',
appId: 'connect-settings',
},
{
component: defineAsyncComponent(() => import('../DownloadApiLogs.standalone.vue')),
selector: 'unraid-download-api-logs',
appId: 'download-api-logs',
},
{
component: defineAsyncComponent(() => import('@/components/Modals.standalone.vue')),
selector: ['unraid-modals', '#modals', 'modals-direct'], // All possible modal selectors
appId: 'modals',
},
{
component: defineAsyncComponent(() => import('../Registration.standalone.vue')),
selector: 'unraid-registration',
appId: 'registration',
},
{
loader: () => import('../WanIpCheck.standalone.vue'),
component: defineAsyncComponent(() => import('../WanIpCheck.standalone.vue')),
selector: 'unraid-wan-ip-check',
appId: 'wan-ip-check',
},
{
loader: () => import('../CallbackHandler.standalone.vue'),
component: defineAsyncComponent(() => import('../CallbackHandler.standalone.vue')),
selector: 'unraid-callback-handler',
appId: 'callback-handler',
},
{
loader: () => import('../Logs/LogViewer.standalone.vue'),
component: defineAsyncComponent(() => import('../Logs/LogViewer.standalone.vue')),
selector: 'unraid-log-viewer',
appId: 'log-viewer',
},
{
loader: () => import('../SsoButton.standalone.vue'),
component: defineAsyncComponent(() => import('../SsoButton.standalone.vue')),
selector: 'unraid-sso-button',
appId: 'sso-button',
},
{
loader: () => import('../Activation/WelcomeModal.standalone.vue'),
component: defineAsyncComponent(() => import('../Activation/WelcomeModal.standalone.vue')),
selector: 'unraid-welcome-modal',
appId: 'welcome-modal',
},
{
loader: () => import('../UpdateOs.standalone.vue'),
component: defineAsyncComponent(() => import('../UpdateOs.standalone.vue')),
selector: 'unraid-update-os',
appId: 'update-os',
},
{
loader: () => import('../DowngradeOs.standalone.vue'),
component: defineAsyncComponent(() => import('../DowngradeOs.standalone.vue')),
selector: 'unraid-downgrade-os',
appId: 'downgrade-os',
},
{
loader: () => import('../DevSettings.vue'),
component: defineAsyncComponent(() => import('../DevSettings.vue')),
selector: 'unraid-dev-settings',
appId: 'dev-settings',
},
{
loader: () => import('../ApiKeyPage.standalone.vue'),
component: defineAsyncComponent(() => import('../ApiKeyPage.standalone.vue')),
selector: ['unraid-apikey-page', 'unraid-api-key-manager'],
appId: 'apikey-page',
},
{
loader: () => import('../ApiKeyAuthorize.standalone.vue'),
component: defineAsyncComponent(() => import('../ApiKeyAuthorize.standalone.vue')),
selector: 'unraid-apikey-authorize',
appId: 'apikey-authorize',
},
{
loader: () => import('../DevModalTest.standalone.vue'),
component: defineAsyncComponent(() => import('../DevModalTest.standalone.vue')),
selector: 'unraid-dev-modal-test',
appId: 'dev-modal-test',
},
{
loader: () => import('../LayoutViews/Detail/DetailTest.standalone.vue'),
component: defineAsyncComponent(() => import('../LayoutViews/Detail/DetailTest.standalone.vue')),
selector: 'unraid-detail-test',
appId: 'detail-test',
},
{
component: ThemeSwitcherCe, // Static import - theme switcher
component: defineAsyncComponent(() => import('@/components/ThemeSwitcher.standalone.vue')),
selector: 'unraid-theme-switcher',
appId: 'theme-switcher',
},
{
loader: () => import('../ColorSwitcher.standalone.vue'),
component: defineAsyncComponent(() => import('../ColorSwitcher.standalone.vue')),
selector: 'unraid-color-switcher',
appId: 'color-switcher',
},
{
component: UnraidToaster, // Static import - toaster styles need to be in main bundle
component: defineAsyncComponent(() => import('@/components/UnraidToaster.vue')),
selector: ['unraid-toaster', 'uui-toaster'],
appId: 'toaster',
},
{
loader: () => import('../UpdateOs/TestUpdateModal.standalone.vue'),
component: defineAsyncComponent(() => import('../UpdateOs/TestUpdateModal.standalone.vue')),
selector: 'unraid-test-update-modal',
appId: 'test-update-modal',
},
{
loader: () => import('../TestThemeSwitcher.standalone.vue'),
component: defineAsyncComponent(() => import('../TestThemeSwitcher.standalone.vue')),
selector: 'unraid-test-theme-switcher',
appId: 'test-theme-switcher',
},

View File

@@ -1,17 +1,16 @@
import { createApp, defineAsyncComponent, h } from 'vue';
import { createApp, createVNode, h, render } from 'vue';
import { createI18n } from 'vue-i18n';
import { DefaultApolloClient } from '@vue/apollo-composable';
import UApp from '@nuxt/ui/components/App.vue';
import ui from '@nuxt/ui/vue-plugin';
import { ensureTeleportContainer } from '@unraid/ui';
// Import component registry (only imported here to avoid ordering issues)
import { componentMappings } from '@/components/Wrapper/component-registry';
import { client } from '~/helpers/create-apollo-client';
import { createHtmlEntityDecoder } from '~/helpers/i18n-utils';
import en_US from '~/locales/en_US.json';
import type { Component, App as VueApp } from 'vue';
import type { App as VueApp } from 'vue';
// Import Pinia for use in Vue apps
import { globalPinia } from '~/store/globalPinia';
@@ -19,33 +18,15 @@ import { globalPinia } from '~/store/globalPinia';
// Ensure Apollo client is singleton
const apolloClient = (typeof window !== 'undefined' && window.apolloClient) || client;
// Global store for mounted apps
const mountedApps = new Map<string, VueApp>();
const mountedAppClones = new Map<string, VueApp[]>();
const mountedAppContainers = new Map<string, HTMLElement[]>();
// Registry to track selector aliases - maps each selector to its canonical appId
const selectorRegistry = new Map<string, string>(); // shadow-root containers for cleanup
// Extend HTMLElement to include Vue's internal properties
interface HTMLElementWithVue extends HTMLElement {
__vueParentComponent?: {
appContext?: {
app?: VueApp;
};
};
}
// Expose globally for debugging
declare global {
interface Window {
mountedApps: Map<string, VueApp>;
globalPinia: typeof globalPinia;
__unifiedApp?: VueApp;
}
}
if (typeof window !== 'undefined') {
window.mountedApps = mountedApps;
window.globalPinia = globalPinia;
}
@@ -81,16 +62,6 @@ function setupI18n() {
});
}
export interface MountOptions {
component: Component;
selector: string | string[]; // Can be a single selector or array of selector aliases
appId?: string;
useShadowRoot?: boolean;
props?: Record<string, unknown>;
skipRecovery?: boolean; // Internal flag to prevent recursive recovery attempts
waitForElement?: boolean; // If true, poll for element existence before mounting
}
// Helper function to parse props from HTML attributes
function parsePropsFromElement(element: Element): Record<string, unknown> {
const props: Record<string, unknown> = {};
@@ -127,565 +98,103 @@ function parsePropsFromElement(element: Element): Record<string, unknown> {
return props;
}
export function mountVueApp(options: MountOptions): VueApp | null {
const {
component,
selector,
appId,
useShadowRoot = false,
props = {},
skipRecovery = false,
waitForElement = false,
} = options;
// Normalize selector to array
const selectors = Array.isArray(selector) ? selector : [selector];
// Generate appId from first selector if not provided
const canonicalAppId = appId || selectors[0];
// Check if any of the selectors are already registered (singleton check)
for (const sel of selectors) {
if (selectorRegistry.has(sel)) {
const existingAppId = selectorRegistry.get(sel)!;
if (mountedApps.has(existingAppId)) {
console.debug(
`[VueMountApp] Component already mounted as ${existingAppId} for selector ${sel}, returning existing instance`
);
return mountedApps.get(existingAppId)!;
}
}
}
// Check if app is already mounted by its ID
if (mountedApps.has(canonicalAppId)) {
console.warn(`[VueMountApp] App ${canonicalAppId} is already mounted`);
return mountedApps.get(canonicalAppId)!;
}
// If waitForElement is true, poll for element existence
if (waitForElement) {
const tryMount = () => {
// Check if any of the selectors have elements
for (const sel of selectors) {
const elements = document.querySelectorAll(sel);
if (elements.length > 0) {
try {
// Element found, mount immediately with this selector
mountVueApp({ ...options, selector: sel, waitForElement: false });
} catch (error) {
console.error(`[VueMountApp] Failed to mount ${appId || sel} during async mount:`, error);
// Don't retry this component to avoid infinite loops
}
return;
}
}
// No elements found, try again later
setTimeout(tryMount, 100);
};
// Start polling when DOM is ready
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', tryMount);
} else {
tryMount();
}
return null; // Return null for async mounting
}
// Find the first selector that has elements in the DOM
let activeSelector: string | null = null;
for (const sel of selectors) {
if (document.querySelector(sel)) {
activeSelector = sel;
break;
}
}
if (!activeSelector) {
console.warn(`[VueMountApp] No elements found for any selector: ${selectors.join(', ')}`);
return null;
}
// Register all selectors as aliases for this app
for (const sel of selectors) {
selectorRegistry.set(sel, canonicalAppId);
}
// Check if any elements matching the selector already have Vue apps mounted
const potentialTargets = document.querySelectorAll(activeSelector);
for (const target of potentialTargets) {
const element = target as HTMLElementWithVue;
const hasVueAttributes =
element.hasAttribute('data-vue-mounted') ||
element.hasAttribute('data-v-app') ||
element.hasAttribute('data-server-rendered');
if (hasVueAttributes || element.__vueParentComponent) {
// Check if the existing Vue component is actually working (has content)
const hasContent = element.innerHTML.trim().length > 0 || element.children.length > 0;
if (hasContent) {
console.info(
`[VueMountApp] Element ${selector} already has working Vue component, skipping remount`
);
// Return the existing app if we can find it
const existingApp = mountedApps.get(canonicalAppId);
if (existingApp) {
return existingApp;
}
// If we can't find the app reference but component is working, return null (success)
return null;
}
console.warn(`[VueMountApp] Element ${selector} has Vue attributes but no content, cleaning up`);
try {
// DO NOT attempt to unmount existing Vue instances - this causes the nextSibling error
// Instead, just clear the DOM state and let Vue handle the cleanup naturally
// Remove all Vue-related attributes
element.removeAttribute('data-vue-mounted');
element.removeAttribute('data-v-app');
element.removeAttribute('data-server-rendered');
// Remove any Vue-injected attributes
Array.from(element.attributes).forEach((attr) => {
if (attr.name.startsWith('data-v-')) {
element.removeAttribute(attr.name);
}
});
// Clear the element content to ensure fresh state
element.innerHTML = '';
// Remove the __vueParentComponent reference without calling unmount
delete element.__vueParentComponent;
console.info(
`[VueMountApp] Cleared Vue state from ${activeSelector} without unmounting (prevents nextSibling errors)`
);
} catch (error) {
console.warn(`[VueMountApp] Error cleaning up existing Vue instance:`, error);
// Force clear everything if normal cleanup fails
element.innerHTML = '';
element.removeAttribute('data-vue-mounted');
element.removeAttribute('data-v-app');
element.removeAttribute('data-server-rendered');
// Remove all data-v-* attributes
Array.from(element.attributes).forEach((attr) => {
if (attr.name.startsWith('data-v-')) {
element.removeAttribute(attr.name);
}
});
}
}
}
// Find all mount targets
const targets = document.querySelectorAll(activeSelector);
if (targets.length === 0) {
console.warn(`[VueMountApp] No elements found for selector: ${activeSelector}`);
return null;
}
// Ensure teleport container exists before mounting
ensureTeleportContainer();
// For the first target, parse props from HTML attributes
const firstTarget = targets[0];
const parsedProps = { ...parsePropsFromElement(firstTarget), ...props };
// Create the Vue app wrapped with UApp for proper Nuxt UI functionality
// Create and mount unified app with shared context
export function mountUnifiedApp() {
// Create a minimal app just for context sharing
const app = createApp({
name: 'StandaloneAppWrapper',
setup() {
// Delay component creation until setup to ensure app context is ready
return () =>
h(
UApp,
{},
{
default: () => h(component, parsedProps),
}
);
},
name: 'UnifiedContextApp',
render: () => h('div', 'Context Provider'),
});
// Setup i18n
// Setup everything once
const i18n = setupI18n();
app.use(i18n);
// Use the shared Pinia instance - this makes it available in the app context
app.use(globalPinia);
// Nuxt UI plugin
app.use(ui);
// Provide Apollo client
app.provide(DefaultApolloClient, apolloClient);
// UI config removed - not available
// Mount the app to establish context
let rootElement = document.getElementById('unraid-unified-root');
if (!rootElement) {
rootElement = document.createElement('div');
rootElement.id = 'unraid-unified-root';
rootElement.style.display = 'none';
document.body.appendChild(rootElement);
}
app.mount(rootElement);
// Mount to all targets
const clones: VueApp[] = [];
const containers: HTMLElement[] = [];
targets.forEach((target, index) => {
const mountTarget = target as HTMLElement;
// Now render components to their locations using the shared context
const mountedComponents: Array<{ element: HTMLElement; unmount: () => void }> = [];
// Comprehensive DOM validation
if (!mountTarget.isConnected || !mountTarget.parentNode || !document.contains(mountTarget)) {
console.warn(`[VueMountApp] Mount target not properly connected to DOM for ${appId}, skipping`);
return;
}
// Components are already in priority order in component-registry
componentMappings.forEach((mapping) => {
const { selector, appId } = mapping;
const selectors = Array.isArray(selector) ? selector : [selector];
// Special handling for PHP-generated pages that might have whitespace/comment nodes
if (mountTarget.childNodes.length > 0) {
let hasProblematicNodes = false;
const nodesToRemove: Node[] = [];
// Find first matching element
for (const sel of selectors) {
const element = document.querySelector(sel) as HTMLElement;
if (element && !element.hasAttribute('data-vue-mounted')) {
// Get the async component from mapping
const component = mapping.component;
Array.from(mountTarget.childNodes).forEach((node) => {
// Check for orphaned nodes
if (node.parentNode !== mountTarget) {
hasProblematicNodes = true;
return;
// Skip if no component is defined
if (!component) {
console.error(`[UnifiedMount] No component defined for ${appId}`);
continue;
}
// Check for empty text nodes or comments that could cause fragment issues
if (node.nodeType === Node.TEXT_NODE && node.textContent?.trim() === '') {
nodesToRemove.push(node);
hasProblematicNodes = true;
} else if (node.nodeType === Node.COMMENT_NODE) {
nodesToRemove.push(node);
hasProblematicNodes = true;
}
});
// Parse props from element
const props = parsePropsFromElement(element);
if (hasProblematicNodes) {
console.warn(`[VueMountApp] Cleaning up problematic nodes in ${selector} before mounting`);
// Remove problematic nodes
nodesToRemove.forEach((node) => {
try {
if (node.parentNode) {
node.parentNode.removeChild(node);
}
} catch (_e) {
// If removal fails, clear the entire content
mountTarget.innerHTML = '';
}
});
// If we still have orphaned nodes after cleanup, clear everything
const remainingInvalidChildren = Array.from(mountTarget.childNodes).filter((node) => {
return node.parentNode !== mountTarget;
});
if (remainingInvalidChildren.length > 0) {
console.warn(
`[VueMountApp] Clearing all content due to remaining orphaned nodes in ${selector}`
);
mountTarget.innerHTML = '';
}
}
}
// Add unapi class for minimal styling and mark as mounted
mountTarget.classList.add('unapi');
mountTarget.setAttribute('data-vue-mounted', 'true');
if (useShadowRoot) {
// Create shadow root if needed
if (!mountTarget.shadowRoot) {
mountTarget.attachShadow({ mode: 'open' });
}
// Create mount container in shadow root
const container = document.createElement('div');
container.id = 'app';
container.setAttribute('data-app-id', canonicalAppId);
mountTarget.shadowRoot!.appendChild(container);
containers.push(container);
// For the first target, use the main app, otherwise create clones
if (index === 0) {
try {
app.mount(container);
} catch (error) {
console.error(`[VueMountApp] Error mounting main app to shadow root ${selector}:`, error);
throw error;
}
} else {
const targetProps = { ...parsePropsFromElement(mountTarget), ...props };
const clonedApp = createApp({
name: 'StandaloneAppWrapperClone',
// Wrap component in UApp for Nuxt UI support
const wrappedComponent = {
name: `${appId}-wrapper`,
setup() {
return () =>
h(
UApp,
{},
{
default: () => h(component, targetProps),
default: () => h(component, props),
}
);
},
};
// Create vnode with shared app context
const vnode = createVNode(wrappedComponent);
vnode.appContext = app._context; // Share the app context
// Clear the element and render the component into it
element.innerHTML = '';
render(vnode, element);
// Mark as mounted
element.setAttribute('data-vue-mounted', 'true');
element.classList.add('unapi');
// Store for cleanup
mountedComponents.push({
element,
unmount: () => render(null, element),
});
clonedApp.use(i18n);
clonedApp.use(globalPinia);
clonedApp.use(ui);
clonedApp.provide(DefaultApolloClient, apolloClient);
try {
clonedApp.mount(container);
clones.push(clonedApp);
} catch (error) {
console.error(`[VueMountApp] Error mounting cloned app to shadow root ${selector}:`, error);
// Don't call unmount since mount failed - just let the app be garbage collected
}
}
} else {
// Direct mount without shadow root
// For multiple targets, we need to create separate app instances
// but they'll share the same Pinia store
if (index === 0) {
// First target, use the main app
try {
// Final validation before mounting
if (!mountTarget.isConnected || !document.contains(mountTarget)) {
throw new Error(`Mount target disconnected before mounting: ${selector}`);
}
app.mount(mountTarget);
} catch (error) {
console.error(`[VueMountApp] Error mounting main app to ${selector}:`, error);
// Special handling for nextSibling error - attempt recovery (only if not already retrying)
if (!skipRecovery && error instanceof TypeError && error.message.includes('nextSibling')) {
console.warn(`[VueMountApp] Attempting recovery from nextSibling error for ${selector}`);
// Remove the problematic data attribute that might be causing issues
mountTarget.removeAttribute('data-vue-mounted');
// Try mounting immediately
try {
// Ensure element is still valid
if (mountTarget.isConnected && document.contains(mountTarget)) {
app.mount(mountTarget);
mountTarget.setAttribute('data-vue-mounted', 'true');
console.info(
`[VueMountApp] Successfully recovered from nextSibling error for ${selector}`
);
} else {
console.error(`[VueMountApp] Recovery failed - element no longer in DOM: ${selector}`);
}
} catch (retryError) {
console.error(`[VueMountApp] Recovery attempt failed for ${selector}:`, retryError);
}
// Return without throwing to allow other elements to mount
return;
}
// Don't throw error - just return null to allow other components to mount
// The error has already been logged
return null;
}
} else {
// Additional targets, create cloned apps with their own props
const targetProps = { ...parsePropsFromElement(mountTarget), ...props };
const clonedApp = createApp({
name: 'StandaloneAppWrapperClone',
setup() {
return () =>
h(
UApp,
{},
{
default: () => h(component, targetProps),
}
);
},
});
clonedApp.use(i18n);
clonedApp.use(globalPinia); // Shared Pinia instance
clonedApp.use(ui);
clonedApp.provide(DefaultApolloClient, apolloClient);
try {
clonedApp.mount(mountTarget);
clones.push(clonedApp);
} catch (error) {
console.error(`[VueMountApp] Error mounting cloned app to ${selector}:`, error);
// Don't call unmount since mount failed - just let the app be garbage collected
}
break;
}
}
});
// Store the app reference
mountedApps.set(canonicalAppId, app);
if (clones.length) mountedAppClones.set(canonicalAppId, clones);
if (containers.length) mountedAppContainers.set(canonicalAppId, containers);
// Store reference for debugging
if (typeof window !== 'undefined') {
window.__unifiedApp = app;
window.__mountedComponents = mountedComponents;
}
return app;
}
export function unmountVueApp(appId: string): boolean {
const app = mountedApps.get(appId);
if (!app) {
console.warn(`[VueMountApp] No app found with id: ${appId}`);
return false;
}
// Clean up selector registry - remove all selectors that point to this appId
for (const [selector, registeredAppId] of selectorRegistry.entries()) {
if (registeredAppId === appId) {
selectorRegistry.delete(selector);
}
}
// Unmount clones first with error handling
const clones = mountedAppClones.get(appId) ?? [];
for (const c of clones) {
try {
c.unmount();
} catch (error) {
console.warn(`[VueMountApp] Error unmounting clone for ${appId}:`, error);
}
}
mountedAppClones.delete(appId);
// Remove shadow containers with error handling
const containers = mountedAppContainers.get(appId) ?? [];
for (const el of containers) {
try {
el.remove();
} catch (error) {
console.warn(`[VueMountApp] Error removing container for ${appId}:`, error);
}
}
mountedAppContainers.delete(appId);
// Unmount main app with error handling
try {
app.unmount();
// Clean up data attributes from mounted elements
const elements = document.querySelectorAll(`[data-vue-mounted="true"]`);
elements.forEach((el) => {
if (el.classList.contains('unapi')) {
el.removeAttribute('data-vue-mounted');
}
});
} catch (error) {
console.warn(`[VueMountApp] Error unmounting app ${appId}:`, error);
}
mountedApps.delete(appId);
return true;
}
export function getMountedApp(appId: string): VueApp | undefined {
return mountedApps.get(appId);
}
// Auto-mount function that waits for DOM elements to be available
export function autoMountComponent(
componentOrMapping: Component | { component?: Component; loader?: () => Promise<VueComponentModule> },
selector: string | string[],
options?: Partial<MountOptions>
) {
let component: Component;
// Handle different input types
if ('component' in componentOrMapping && componentOrMapping.component) {
// Direct component from mapping
component = componentOrMapping.component;
} else if ('loader' in componentOrMapping && componentOrMapping.loader) {
// Async loader from mapping - create async component
component = createAsyncComponent(componentOrMapping.loader);
} else if (
typeof componentOrMapping === 'object' &&
!('component' in componentOrMapping) &&
!('loader' in componentOrMapping)
) {
// Direct component passed
component = componentOrMapping as Component;
} else {
console.error('[autoMountComponent] Invalid component or mapping provided');
return;
}
// Delegate to mountVueApp with waitForElement option
mountVueApp({
component,
selector,
...options,
waitForElement: true,
});
}
// Type for Vue component module
type VueComponentModule = { default: object } | object;
// Helper to create async components with consistent error handling
export function createAsyncComponent(loader: () => Promise<VueComponentModule>) {
return defineAsyncComponent({
loader: async () => {
const module = await loader();
return 'default' in module ? module.default : module;
},
loadingComponent: undefined,
errorComponent: undefined,
delay: 0,
timeout: 5000, // 5 second timeout
onError(error, _retry, fail) {
console.error('[AsyncComponent] Failed to load component:', error);
fail();
},
});
}
// Auto-mount all registered components from component-registry
// Replace the old autoMountAllComponents with the new unified approach
export function autoMountAllComponents() {
console.log('[AutoMountAll] Starting auto-mount for', componentMappings.length, 'components');
componentMappings.forEach((mapping) => {
const { selector, appId } = mapping;
// Normalize selector to array for consistent handling
const selectors = Array.isArray(selector) ? selector : [selector];
// Check if any of the selectors have elements in the DOM
const hasElements = selectors.some((sel) => {
const found = document.querySelector(sel);
if (found) {
console.log(`[AutoMountAll] Found element for selector: ${sel}`);
return true;
}
return false;
});
// Only proceed if at least one selector has elements
if (hasElements) {
console.log(`[AutoMountAll] Mounting component: ${appId}`);
try {
// Pass the mapping directly to autoMountComponent
// Let mount-engine handle component vs loader logic
autoMountComponent(mapping, selector, {
appId,
useShadowRoot: false,
});
} catch (error) {
console.error(`[AutoMountAll] Failed to mount ${appId}:`, error);
// Continue with next component
}
} else {
console.log(`[AutoMountAll] No elements found for: ${selectors.join(', ')}`);
}
});
console.log('[AutoMountAll] Auto-mount complete');
mountUnifiedApp();
}

View File

@@ -1,5 +1,9 @@
declare global {
var csrf_token: string;
interface Window {
__unifiedApp?: unknown;
__mountedComponents?: Array<{ element: HTMLElement; unmount: () => void }>;
}
}
// an export or import statement is required to make this file a module

View File

@@ -92,6 +92,15 @@ export default defineConfig({
},
},
optimizeDeps: {
include: ['ajv', 'ajv-errors'],
esbuildOptions: {
define: {
global: 'globalThis',
},
},
},
define: {
...sharedDefine,
'process.env.NODE_ENV': JSON.stringify(process.env.NODE_ENV || 'production'),