feat: mount vue apps, not web components (#1639)

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

* **New Features**
* Standalone web bundle with auto-mount utilities and a self-contained
test page.
* New responsive modal components for consistent mobile/desktop dialogs.
  * Header actions to copy OS/API versions.

* **Improvements**
* Refreshed UI styles (muted borders), accessibility and animation
refinements.
  * Theming updates and Tailwind v4–aligned, component-scoped styles.
  * Runtime GraphQL endpoint override and CSRF header support.

* **Bug Fixes**
* Safer network fetching and improved manifest/asset loading with
duplicate protection.

* **Tests/Chores**
* Parallel plugin tests, new extractor test suite, and updated
build/test scripts.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
This commit is contained in:
Eli Bosley
2025-09-03 15:42:21 -04:00
committed by GitHub
parent 5d89682a3f
commit 88087d5201
121 changed files with 3632 additions and 1816 deletions

View File

@@ -4,6 +4,7 @@ import {
setupPluginEnv,
} from "../../cli/setup-plugin-environment";
import { access, readFile } from "node:fs/promises";
import { existsSync } from "node:fs";
// Mock fs/promises
vi.mock("node:fs/promises", () => ({
@@ -14,8 +15,19 @@ vi.mock("node:fs/promises", () => ({
},
}));
// Mock node:fs
vi.mock("node:fs", () => ({
existsSync: vi.fn(),
}));
beforeEach(() => {
vi.resetAllMocks();
// Mock existsSync to return true for test.txz
vi.mocked(existsSync).mockImplementation((path) => {
return path.toString().includes("test.txz");
});
vi.mocked(readFile).mockImplementation((path, encoding) => {
console.log("Mock readFile called with:", path, encoding);
@@ -42,6 +54,7 @@ describe("validatePluginEnv", () => {
it("validates required fields", async () => {
const validEnv = {
apiVersion: "4.17.0",
baseUrl: "https://example.com",
txzPath: "./test.txz",
pluginVersion: "2024.05.05.1232",
@@ -53,6 +66,7 @@ describe("validatePluginEnv", () => {
it("throws on invalid URL", async () => {
const invalidEnv = {
apiVersion: "4.17.0",
baseUrl: "not-a-url",
txzPath: "./test.txz",
pluginVersion: "2024.05.05.1232",
@@ -63,6 +77,7 @@ describe("validatePluginEnv", () => {
it("handles tag option in non-CI mode", async () => {
const envWithTag = {
apiVersion: "4.17.0",
baseUrl: "https://example.com",
txzPath: "./test.txz",
pluginVersion: "2024.05.05.1232",
@@ -77,6 +92,7 @@ describe("validatePluginEnv", () => {
it("reads release notes when release-notes-path is provided", async () => {
const envWithNotes = {
apiVersion: "4.17.0",
baseUrl: "https://example.com",
txzPath: "./test.txz",
pluginVersion: "2024.05.05.1232",
@@ -100,6 +116,7 @@ describe("validatePluginEnv", () => {
});
const envWithEmptyNotes = {
apiVersion: "4.17.0",
baseUrl: "https://example.com",
txzPath: "./test.txz",
pluginVersion: "2024.05.05.1232",

View File

@@ -6,8 +6,20 @@ import { deployDir } from "../../utils/paths";
describe("setupTxzEnvironment", () => {
it("should return default values when no arguments are provided", async () => {
const envArgs = {};
const expected: TxzEnv = { ci: false, skipValidation: "false", compress: "1", txzOutputDir: join(startingDir, deployDir) };
const envArgs = {
apiVersion: "4.17.0",
baseUrl: "https://example.com"
};
const expected: TxzEnv = {
apiVersion: "4.17.0",
baseUrl: "https://example.com",
ci: false,
skipValidation: "false",
compress: "1",
txzOutputDir: join(startingDir, deployDir),
tag: "",
buildNumber: 1
};
const result = await validateTxzEnv(envArgs);
@@ -15,8 +27,24 @@ describe("setupTxzEnvironment", () => {
});
it("should parse and return provided environment arguments", async () => {
const envArgs = { ci: true, skipValidation: "true", txzOutputDir: join(startingDir, "deploy/release/test"), compress: '8' };
const expected: TxzEnv = { ci: true, skipValidation: "true", compress: "8", txzOutputDir: join(startingDir, "deploy/release/test") };
const envArgs = {
apiVersion: "4.17.0",
baseUrl: "https://example.com",
ci: true,
skipValidation: "true",
txzOutputDir: join(startingDir, "deploy/release/test"),
compress: '8'
};
const expected: TxzEnv = {
apiVersion: "4.17.0",
baseUrl: "https://example.com",
ci: true,
skipValidation: "true",
compress: "8",
txzOutputDir: join(startingDir, "deploy/release/test"),
tag: "",
buildNumber: 1
};
const result = await validateTxzEnv(envArgs);
@@ -24,7 +52,11 @@ describe("setupTxzEnvironment", () => {
});
it("should warn and skip validation when skipValidation is true", async () => {
const envArgs = { skipValidation: "true" };
const envArgs = {
apiVersion: "4.17.0",
baseUrl: "https://example.com",
skipValidation: "true"
};
const consoleWarnSpy = vi
.spyOn(console, "warn")
.mockImplementation(() => {});
@@ -38,7 +70,11 @@ describe("setupTxzEnvironment", () => {
});
it("should throw an error for invalid SKIP_VALIDATION value", async () => {
const envArgs = { skipValidation: "invalid" };
const envArgs = {
apiVersion: "4.17.0",
baseUrl: "https://example.com",
skipValidation: "invalid"
};
await expect(validateTxzEnv(envArgs)).rejects.toThrow(
"Must be true or false"

View File

@@ -29,7 +29,9 @@ const findManifestFiles = async (dir: string): Promise<string[]> => {
}
} else if (
entry.isFile() &&
(entry.name === "manifest.json" || entry.name === "ui.manifest.json")
(entry.name === "manifest.json" ||
entry.name === "ui.manifest.json" ||
entry.name === "standalone.manifest.json")
) {
files.push(entry.name);
}
@@ -124,19 +126,21 @@ const validateSourceDir = async (validatedEnv: TxzEnv) => {
const manifestFiles = await findManifestFiles(webcomponentDir);
const hasManifest = manifestFiles.includes("manifest.json");
const hasStandaloneManifest = manifestFiles.includes("standalone.manifest.json");
const hasUiManifest = manifestFiles.includes("ui.manifest.json");
if (!hasManifest || !hasUiManifest) {
// Accept either manifest.json (old web components) or standalone.manifest.json (new standalone apps)
if ((!hasManifest && !hasStandaloneManifest) || !hasUiManifest) {
console.log("Existing Manifest Files:", manifestFiles);
const missingFiles: string[] = [];
if (!hasManifest) missingFiles.push("manifest.json");
if (!hasManifest && !hasStandaloneManifest) missingFiles.push("manifest.json or standalone.manifest.json");
if (!hasUiManifest) missingFiles.push("ui.manifest.json");
throw new Error(
`Webcomponents missing required file(s): ${missingFiles.join(", ")} - ` +
`${!hasUiManifest ? "run 'pnpm build:wc' in unraid-ui for ui.manifest.json" : ""}` +
`${!hasManifest && !hasUiManifest ? " and " : ""}` +
`${!hasManifest ? "run 'pnpm build' in web for manifest.json" : ""}`
`${(!hasManifest && !hasStandaloneManifest) && !hasUiManifest ? " and " : ""}` +
`${(!hasManifest && !hasStandaloneManifest) ? "run 'pnpm build' in web for standalone.manifest.json" : ""}`
);
}

View File

@@ -22,7 +22,7 @@ export const validateTxzEnv = async (
): Promise<TxzEnv> => {
const validatedEnv = txzEnvSchema.parse(envArgs);
if ("skipValidation" in validatedEnv) {
if (validatedEnv.skipValidation === "true") {
console.warn("skipValidation is true, skipping validation");
}

View File

@@ -32,7 +32,8 @@
"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"
"test": "vitest && pnpm run test:extractor",
"test:extractor": "bash ./tests/test-extractor.sh"
},
"devDependencies": {
"http-server": "14.1.1",

View File

@@ -161,7 +161,7 @@ exit 0
sed -i 's|<body>|<body>\n<unraid-modals></unraid-modals>|' "/usr/local/emhttp/plugins/dynamix/include/DefaultPageLayout.php"
fi
fi
]]>
</INLINE>
</FILE>
@@ -272,6 +272,27 @@ exit 0
[ -f "$FILE-" ] && mv -f "$FILE-" "$FILE"
done
# Restore CSS files from backup
echo "Restoring original CSS files..."
CSS_DIR="/usr/local/emhttp/plugins/dynamix/styles"
BACKUP_DIR="$CSS_DIR/.unraid-api-backup"
if [ -d "$BACKUP_DIR" ]; then
for backup_file in "$BACKUP_DIR"/*.css; do
if [ -f "$backup_file" ]; then
filename=$(basename "$backup_file")
original_file="$CSS_DIR/$filename"
echo " Restoring $filename..."
cp "$backup_file" "$original_file"
fi
done
# Remove backup directory after restoration
rm -rf "$BACKUP_DIR"
echo "CSS restoration complete."
else
echo "No CSS backup found, skipping restoration."
fi
# Handle the unraid-components directory
DIR=/usr/local/emhttp/plugins/dynamix.my.servers/unraid-components
# Remove the archive's contents before restoring

View File

@@ -4,10 +4,6 @@ $docroot = $docroot ?? $_SERVER['DOCUMENT_ROOT'] ?: '/usr/local/emhttp';
class WebComponentsExtractor
{
private const PREFIXED_PATH = '/plugins/dynamix.my.servers/unraid-components/';
private const RICH_COMPONENTS_ENTRY = 'unraid-components.client.mjs';
private const RICH_COMPONENTS_ENTRY_JS = 'unraid-components.client.js';
private const UI_ENTRY = 'src/register.ts';
private const UI_STYLES_ENTRY = 'style.css';
private static ?WebComponentsExtractor $instance = null;
@@ -21,15 +17,6 @@ class WebComponentsExtractor
return self::$instance;
}
private function findManifestFiles(string $manifestName): array
{
$basePath = '/usr/local/emhttp' . self::PREFIXED_PATH;
$escapedBasePath = escapeshellarg($basePath);
$escapedManifestName = escapeshellarg($manifestName);
$command = "find {$escapedBasePath} -name {$escapedManifestName}";
exec($command, $files);
return $files;
}
public function getAssetPath(string $asset, string $subfolder = ''): string
{
@@ -42,6 +29,11 @@ class WebComponentsExtractor
$relative = str_replace($basePath, '', $fullPath);
return dirname($relative);
}
private function sanitizeForId(string $input): string
{
return preg_replace('/[^a-zA-Z0-9-]/', '-', $input);
}
public function getManifestContents(string $manifestPath): array
{
@@ -49,117 +41,108 @@ class WebComponentsExtractor
return $contents ? json_decode($contents, true) : [];
}
private function getRichComponentsFile(): string
private function processManifestFiles(): string
{
$manifestFiles = $this->findManifestFiles('manifest.json');
// Find all manifest files (*.manifest.json or manifest.json)
$manifestFiles = $this->findAllManifestFiles();
if (empty($manifestFiles)) {
return '';
}
$scripts = [];
// Process each manifest file
foreach ($manifestFiles as $manifestPath) {
$manifest = $this->getManifestContents($manifestPath);
if (empty($manifest)) {
continue;
}
$subfolder = $this->getRelativePath($manifestPath);
foreach ($manifest as $key => $value) {
// Skip timestamp entries
if ($key === 'ts' || !is_array($value)) {
// Process each entry in the manifest
foreach ($manifest as $key => $entry) {
// Skip if not an array with a 'file' key
if (!is_array($entry) || !isset($entry['file']) || empty($entry['file'])) {
continue;
}
// Check for both old format (direct key match) and new format (path-based key)
$matchesMjs = strpos($key, self::RICH_COMPONENTS_ENTRY) !== false;
$matchesJs = strpos($key, self::RICH_COMPONENTS_ENTRY_JS) !== false;
// Build the file path
$filePath = ($subfolder ? $subfolder . '/' : '') . $entry['file'];
$fullPath = $this->getAssetPath($filePath);
if (($matchesMjs || $matchesJs) && isset($value["file"])) {
return ($subfolder ? $subfolder . '/' : '') . $value["file"];
// Determine file type and generate appropriate tag
$extension = pathinfo($entry['file'], PATHINFO_EXTENSION);
// Sanitize subfolder and key for ID generation
$sanitizedSubfolder = $subfolder ? $this->sanitizeForId($subfolder) . '-' : '';
$sanitizedKey = $this->sanitizeForId($key);
// Escape attributes for HTML safety
$safeId = htmlspecialchars('unraid-' . $sanitizedSubfolder . $sanitizedKey, ENT_QUOTES, 'UTF-8');
$safePath = htmlspecialchars($fullPath, ENT_QUOTES, 'UTF-8');
if ($extension === 'js' || $extension === 'mjs') {
// Generate script tag with unique ID based on subfolder and key
$scripts[] = '<script id="' . $safeId . '" type="module" src="' . $safePath . '" data-unraid="1"></script>';
// Also emit any CSS referenced by this entry (Vite manifest "css": [])
if (!empty($entry['css']) && is_array($entry['css'])) {
foreach ($entry['css'] as $cssFile) {
if (!is_string($cssFile) || $cssFile === '') continue;
$cssPath = ($subfolder ? $subfolder . '/' : '') . $cssFile;
$cssFull = $this->getAssetPath($cssPath);
$cssId = htmlspecialchars(
'unraid-' . $sanitizedSubfolder . $sanitizedKey . '-css-' . $this->sanitizeForId(basename($cssFile)),
ENT_QUOTES,
'UTF-8'
);
$cssHref = htmlspecialchars($cssFull, ENT_QUOTES, 'UTF-8');
$scripts[] = '<link id="' . $cssId . '" rel="stylesheet" href="' . $cssHref . '" data-unraid="1">';
}
}
} elseif ($extension === 'css') {
// Generate link tag for CSS files with unique ID
$scripts[] = '<link id="' . $safeId . '" rel="stylesheet" href="' . $safePath . '" data-unraid="1">';
}
}
}
return '';
}
private function getRichComponentsScript(): string
{
$jsFile = $this->getRichComponentsFile();
if (empty($jsFile)) {
return '<script>console.error("%cNo matching key containing \'' . self::RICH_COMPONENTS_ENTRY . '\' or \'' . self::RICH_COMPONENTS_ENTRY_JS . '\' found.", "font-weight: bold; color: white; background-color: red");</script>';
if (empty($scripts)) {
return '';
}
// Add a unique identifier to prevent duplicate script loading
$scriptId = 'unraid-rich-components-script';
return '<script id="' . $scriptId . '" src="' . $this->getAssetPath($jsFile) . '"></script>
// Add deduplication script
$deduplicationScript = '
<script>
// Remove duplicate script tags to prevent multiple loads
// Remove duplicate resource tags to prevent multiple loads
(function() {
var scripts = document.querySelectorAll(\'script[id="' . $scriptId . '"]\');
if (scripts.length > 1) {
for (var i = 1; i < scripts.length; i++) {
scripts[i].remove();
var elements = document.querySelectorAll(\'[data-unraid="1"]\');
var seen = {};
for (var i = 0; i < elements.length; i++) {
var el = elements[i];
if (seen[el.id]) {
el.remove();
} else {
seen[el.id] = true;
}
}
})();
</script>';
return implode("\n", $scripts) . $deduplicationScript;
}
private function getUnraidUiScriptHtml(): string
private function findAllManifestFiles(): array
{
$manifestFiles = $this->findManifestFiles('ui.manifest.json');
$basePath = '/usr/local/emhttp' . self::PREFIXED_PATH;
$escapedBasePath = escapeshellarg($basePath);
if (empty($manifestFiles)) {
error_log("No ui.manifest.json found");
return '';
}
$manifestPath = $manifestFiles[0]; // Use the first found manifest
$manifest = $this->getManifestContents($manifestPath);
$subfolder = $this->getRelativePath($manifestPath);
if (!isset($manifest[self::UI_ENTRY]) || !isset($manifest[self::UI_STYLES_ENTRY])) {
error_log("Required entries not found in ui.manifest.json");
return '';
}
$jsFile = ($subfolder ? $subfolder . '/' : '') . $manifest[self::UI_ENTRY]['file'];
$cssFile = ($subfolder ? $subfolder . '/' : '') . $manifest[self::UI_STYLES_ENTRY]['file'];
// Find all files ending with .manifest.json or exactly named manifest.json
$command = "find {$escapedBasePath} -type f \\( -name '*.manifest.json' -o -name 'manifest.json' \\) 2>/dev/null";
exec($command, $files);
// Read the CSS file content
$cssPath = '/usr/local/emhttp' . $this->getAssetPath($cssFile);
$cssContent = @file_get_contents($cssPath);
if ($cssContent === false) {
error_log("Failed to read CSS file: " . $cssPath);
$cssContent = '';
}
// Escape the CSS content for JavaScript
$escapedCssContent = json_encode($cssContent);
// Use a data attribute to ensure this only runs once per page
return '<script data-unraid-ui-register defer type="module">
(async function() {
// Check if components have already been registered
if (window.__unraidUiComponentsRegistered) {
return;
}
// Mark as registered immediately to prevent race conditions
window.__unraidUiComponentsRegistered = true;
try {
const { registerAllComponents } = await import("' . $this->getAssetPath($jsFile) . '");
registerAllComponents({ sharedCssContent: ' . $escapedCssContent . ' });
} catch (error) {
console.error("[Unraid UI] Failed to register components:", error);
// Reset flag on error so it can be retried
window.__unraidUiComponentsRegistered = false;
}
// Clean up duplicate script tags
const scripts = document.querySelectorAll(\'script[data-unraid-ui-register]\');
if (scripts.length > 1) {
for (let i = 1; i < scripts.length; i++) {
scripts[i].remove();
}
}
})();
</script>';
return $files;
}
public function getScriptTagHtml(): string
@@ -168,12 +151,12 @@ class WebComponentsExtractor
static $scriptsOutput = false;
if ($scriptsOutput) {
return '<!-- Web components scripts already loaded -->';
return '<!-- Resources already loaded -->';
}
try {
$scriptsOutput = true;
return $this->getRichComponentsScript() . $this->getUnraidUiScriptHtml();
return $this->processManifestFiles();
} catch (\Exception $e) {
error_log("Error in WebComponentsExtractor::getScriptTagHtml: " . $e->getMessage());
$scriptsOutput = false; // Reset on error

View File

@@ -100,6 +100,57 @@ class UnraidOsCheck
return [];
}
/**
* Safe version of http_get_contents that falls back to system function if defined
* @param string $url The URL to fetch
* @param array $opts Array of options to pass to curl_setopt()
* @param array $getinfo Empty array passed by reference, will contain results of curl_getinfo and curl_error
* Note: CURLINFO_HEADER_OUT exposes request headers (not response headers) for curl_getinfo
* @return string|false $out The fetched content
*/
private function safe_http_get_contents(string $url, array $opts = [], array &$getinfo = NULL) {
// Use system http_get_contents if it exists
if (function_exists('http_get_contents')) {
return http_get_contents($url, $opts, $getinfo);
}
// Otherwise use our implementation
$ch = curl_init();
if(isset($getinfo)) {
curl_setopt($ch, CURLINFO_HEADER_OUT, TRUE);
}
curl_setopt($ch, CURLOPT_URL, $url);
curl_setopt($ch, CURLOPT_FRESH_CONNECT, true);
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
curl_setopt($ch, CURLOPT_CONNECTTIMEOUT, 15);
curl_setopt($ch, CURLOPT_TIMEOUT, 45);
curl_setopt($ch, CURLOPT_ENCODING, "");
curl_setopt($ch, CURLOPT_FOLLOWLOCATION, true);
curl_setopt($ch, CURLOPT_REFERER, "");
curl_setopt($ch, CURLOPT_FAILONERROR, true);
// Explicitly enforce TLS verification
curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, true);
curl_setopt($ch, CURLOPT_SSL_VERIFYHOST, 2);
if(is_array($opts) && $opts) {
foreach($opts as $key => $val) {
curl_setopt($ch, $key, $val);
}
}
$out = curl_exec($ch);
if(isset($getinfo)) {
$getinfo = curl_getinfo($ch);
}
if (curl_errno($ch)) {
$msg = curl_error($ch) . " {$url}";
if(isset($getinfo)) {
$getinfo['error'] = $msg;
}
my_logger($msg, "safe_http_get_contents");
}
curl_close($ch);
return $out;
}
/** @todo clean up this method to be more extensible */
public function checkForUpdate()
{
@@ -136,7 +187,7 @@ class UnraidOsCheck
$urlbase = $parsedAltUrl ?? $defaultUrl;
$url = $urlbase.'?'.http_build_query($params);
$curlinfo = [];
$response = http_get_contents($url,[],$curlinfo);
$response = $this->safe_http_get_contents($url,[],$curlinfo);
if (array_key_exists('error', $curlinfo)) {
$response = json_encode(array('error' => $curlinfo['error']), JSON_PRETTY_PRINT);
}

369
plugin/tests/test-extractor.php Executable file
View File

@@ -0,0 +1,369 @@
#!/usr/bin/env php
<?php
/**
* WebComponentsExtractor Test Suite
*
* Exit codes:
* 0 - All tests passed
* 1 - One or more tests failed
*/
class ExtractorTest {
private $testDir;
private $componentDir;
private $passed = 0;
private $failed = 0;
private $verbose = false;
// Color codes for terminal output
const RED = "\033[0;31m";
const GREEN = "\033[0;32m";
const YELLOW = "\033[1;33m";
const NC = "\033[0m"; // No Color
public function __construct() {
$this->verbose = getenv('VERBOSE') === '1' || in_array('--verbose', $_SERVER['argv'] ?? []);
}
public function run() {
$this->setup();
$this->runTests();
$this->teardown();
return $this->reportResults();
}
private function setup() {
echo "Setting up test environment...\n";
// Create temp directory
$this->testDir = sys_get_temp_dir() . '/extractor_test_' . uniqid();
$this->componentDir = $this->testDir . '/usr/local/emhttp/plugins/dynamix.my.servers/unraid-components';
// Create directory structure
@mkdir($this->componentDir . '/standalone-apps', 0777, true);
@mkdir($this->componentDir . '/ui-components', 0777, true);
@mkdir($this->componentDir . '/other', 0777, true);
// Create test manifest files
file_put_contents($this->componentDir . '/standalone-apps/standalone.manifest.json', json_encode([
'standalone-apps-RlN0czLV.css' => [
'file' => 'standalone-apps-RlN0czLV.css',
'src' => 'standalone-apps-RlN0czLV.css'
],
'standalone-apps.js' => [
'file' => 'standalone-apps.js',
'src' => 'standalone-apps.js',
'css' => ['app-styles.css', 'theme.css']
],
'ts' => 1234567890
], JSON_PRETTY_PRINT));
file_put_contents($this->componentDir . '/ui-components/ui.manifest.json', json_encode([
'ui-styles' => [
'file' => 'ui-components.css'
],
'ui-script' => [
'file' => 'components.mjs'
],
'invalid-entry' => [
'notAFile' => 'should be skipped'
],
'empty-file' => [
'file' => ''
]
], JSON_PRETTY_PRINT));
file_put_contents($this->componentDir . '/other/manifest.json', json_encode([
'app-entry' => [
'file' => 'app.js',
'css' => ['main.css']
],
'app-styles' => [
'file' => 'app.css'
]
], JSON_PRETTY_PRINT));
// Create duplicate key in different subfolder to test collision prevention
file_put_contents($this->componentDir . '/standalone-apps/collision.manifest.json', json_encode([
'app-entry' => [
'file' => 'collision-app.js'
]
], JSON_PRETTY_PRINT));
// Create manifest with special characters for HTML escaping test
file_put_contents($this->componentDir . '/ui-components/special.manifest.json', json_encode([
'test"with>quotes' => [
'file' => 'test-file.js'
],
'test&with<special' => [
'file' => 'special\'file".css'
]
], JSON_PRETTY_PRINT));
// Copy and modify the extractor for testing
$this->prepareExtractor();
}
private function prepareExtractor() {
$extractorPath = dirname(__DIR__) . '/source/dynamix.unraid.net/usr/local/emhttp/plugins/dynamix.my.servers/include/web-components-extractor.php';
$extractorContent = file_get_contents($extractorPath);
// Modify paths for test environment
$extractorContent = str_replace(
"'/usr/local/emhttp' . self::PREFIXED_PATH",
"'" . $this->testDir . "/usr/local/emhttp' . self::PREFIXED_PATH",
$extractorContent
);
file_put_contents($this->testDir . '/extractor.php', $extractorContent);
}
private function getExtractorOutput() {
$_SERVER['DOCUMENT_ROOT'] = '/usr/local/emhttp';
require_once $this->testDir . '/extractor.php';
$extractor = WebComponentsExtractor::getInstance();
return $extractor->getScriptTagHtml();
}
private function runTests() {
echo "\n";
echo "========================================\n";
echo " WebComponentsExtractor Test Suite\n";
echo "========================================\n";
echo "\n";
$output = $this->getExtractorOutput();
if ($this->verbose) {
echo self::YELLOW . "Generated output:" . self::NC . "\n";
echo $output . "\n\n";
}
// Test: Script Tag Generation
echo "Test: Script Tag Generation\n";
echo "----------------------------\n";
$this->test(
"Generates script tag for standalone-apps.js",
strpos($output, 'script id="unraid-standalone-apps-standalone-apps-js"') !== false
);
$this->test(
"Generates script tag for components.mjs",
strpos($output, 'script id="unraid-ui-components-ui-script"') !== false
);
$this->test(
"Generates script tag for app.js",
strpos($output, 'script id="unraid-other-app-entry"') !== false
);
// Test: CSS Link Generation
echo "\nTest: CSS Link Generation\n";
echo "--------------------------\n";
$this->test(
"Generates link tag for standalone CSS",
strpos($output, 'link id="unraid-standalone-apps-standalone-apps-RlN0czLV-css"') !== false
);
$this->test(
"Generates link tag for UI styles",
strpos($output, 'link id="unraid-ui-components-ui-styles"') !== false
);
$this->test(
"Generates link tag for app styles",
strpos($output, 'link id="unraid-other-app-styles"') !== false
);
// Test: Invalid Entries Handling
echo "\nTest: Invalid Entries Handling\n";
echo "-------------------------------\n";
$this->test(
"Skips entries without 'file' key",
strpos($output, 'notAFile') === false
);
$this->test(
"Skips invalid entry content",
strpos($output, 'should be skipped') === false
);
$this->test(
"Skips entries with empty file value",
strpos($output, 'empty-file') === false && strpos($output, 'id="unraid-ui-components-empty-file"') === false
);
// Test: Deduplication Script
echo "\nTest: Deduplication Script\n";
echo "---------------------------\n";
$this->test(
"Includes deduplication script",
strpos($output, 'Remove duplicate resource tags') !== false
);
$this->test(
"Deduplication targets correct elements",
strpos($output, 'document.querySelectorAll') !== false
);
$this->test(
"Deduplication only targets data-unraid elements",
strpos($output, 'querySelectorAll(\'[data-unraid="1"]\')') !== false
);
// Test: Path Construction
echo "\nTest: Path Construction\n";
echo "------------------------\n";
$this->test(
"Correctly constructs standalone-apps path",
strpos($output, '/plugins/dynamix.my.servers/unraid-components/standalone-apps/standalone-apps.js') !== false
);
$this->test(
"Correctly constructs ui-components path",
strpos($output, '/plugins/dynamix.my.servers/unraid-components/ui-components/components.mjs') !== false
);
$this->test(
"Correctly constructs generic manifest path",
strpos($output, '/plugins/dynamix.my.servers/unraid-components/other/app.js') !== false
);
// Test: ID Collision Prevention
echo "\nTest: ID Collision Prevention\n";
echo "------------------------------\n";
$this->test(
"Different IDs for same key in different subfolders (standalone-apps)",
strpos($output, 'id="unraid-standalone-apps-app-entry"') !== false
);
$this->test(
"Different IDs for same key in different subfolders (other)",
strpos($output, 'id="unraid-other-app-entry"') !== false
);
$appEntryCount = substr_count($output, 'id="unraid-') - substr_count($output, 'id="unraid-ui-');
$this->test(
"Both app-entry scripts are present with unique IDs",
preg_match_all('/id="unraid-[^"]*app-entry"/', $output, $matches) === 2
);
// Test: HTML Attribute Escaping
echo "\nTest: HTML Attribute Escaping\n";
echo "------------------------------\n";
$this->test(
"Properly escapes quotes in ID attributes",
strpos($output, '"test"with>quotes"') === false
);
$this->test(
"Properly escapes special characters in ID",
strpos($output, 'unraid-ui-components-test-with-quotes') !== false ||
strpos($output, 'unraid-ui-components-test-with-special') !== false
);
$this->test(
"Properly escapes special characters in src/href attributes",
strpos($output, "special'file\"") === false &&
(strpos($output, 'special&#039;file&quot;') !== false ||
strpos($output, "special&apos;file&quot;") !== false ||
strpos($output, "special'file&quot;") === false)
);
// Test: Data-Unraid Attribute
echo "\nTest: Data-Unraid Attribute\n";
echo "----------------------------\n";
$this->test(
"Script tags have data-unraid attribute",
preg_match('/<script[^>]+data-unraid="1"/', $output) > 0
);
$this->test(
"Link tags have data-unraid attribute",
preg_match('/<link[^>]+data-unraid="1"/', $output) > 0
);
// Test: CSS Loading from Manifest
echo "\nTest: CSS Loading from Manifest\n";
echo "--------------------------------\n";
$this->test(
"Loads CSS from JS entry css array (app-styles.css)",
strpos($output, 'id="unraid-standalone-apps-standalone-apps-js-css-app-styles-css"') !== false
);
$this->test(
"Loads CSS from JS entry css array (theme.css)",
strpos($output, 'id="unraid-standalone-apps-standalone-apps-js-css-theme-css"') !== false
);
$this->test(
"CSS from manifest has correct href path (app-styles.css)",
strpos($output, '/plugins/dynamix.my.servers/unraid-components/standalone-apps/app-styles.css') !== false
);
$this->test(
"CSS from manifest has correct href path (theme.css)",
strpos($output, '/plugins/dynamix.my.servers/unraid-components/standalone-apps/theme.css') !== false
);
$this->test(
"Loads CSS from other JS entry (main.css)",
strpos($output, 'id="unraid-other-app-entry-css-main-css"') !== false
);
$this->test(
"CSS from manifest has data-unraid attribute",
preg_match('/<link[^>]+id="unraid-[^"]*-css-[^"]+"[^>]+data-unraid="1"/', $output) > 0
);
// Test: Duplicate Prevention
echo "\nTest: Duplicate Prevention\n";
echo "---------------------------\n";
// Reset singleton for duplicate test
$reflection = new ReflectionClass('WebComponentsExtractor');
$instance = $reflection->getProperty('instance');
$instance->setAccessible(true);
$instance->setValue(null, null);
$extractor = WebComponentsExtractor::getInstance();
$first = $extractor->getScriptTagHtml();
$second = $extractor->getScriptTagHtml();
$this->test(
"Second call returns 'already loaded' message",
strpos($second, 'Resources already loaded') !== false
);
}
private function test($name, $condition) {
if ($condition) {
echo " " . self::GREEN . "" . self::NC . " " . $name . "\n";
$this->passed++;
} else {
echo " " . self::RED . "" . self::NC . " " . $name . "\n";
$this->failed++;
if ($this->verbose) {
echo " " . self::YELLOW . "Condition failed" . self::NC . "\n";
}
}
}
private function teardown() {
// Clean up temp directory
if ($this->testDir && is_dir($this->testDir)) {
$this->removeDirectory($this->testDir);
}
}
private function removeDirectory($dir) {
if (!is_dir($dir)) return;
$files = array_diff(scandir($dir), ['.', '..']);
foreach ($files as $file) {
$path = $dir . '/' . $file;
is_dir($path) ? $this->removeDirectory($path) : unlink($path);
}
rmdir($dir);
}
private function reportResults() {
echo "\n";
echo "========================================\n";
echo "Test Results:\n";
echo " Passed: " . self::GREEN . $this->passed . self::NC . "\n";
echo " Failed: " . self::RED . $this->failed . self::NC . "\n";
echo "========================================\n";
echo "\n";
if ($this->failed === 0) {
echo self::GREEN . "All tests passed!" . self::NC . "\n";
return 0;
} else {
echo self::RED . "Some tests failed." . self::NC . "\n";
return 1;
}
}
}
// Run tests
$test = new ExtractorTest();
exit($test->run());

16
plugin/tests/test-extractor.sh Executable file
View File

@@ -0,0 +1,16 @@
#!/bin/bash
# WebComponentsExtractor Integration Test
#
# This script runs the PHP test suite for WebComponentsExtractor
# Exit codes:
# 0 - All tests passed
# 1 - One or more tests failed
set -e # Exit on error
# Get the directory of this script
SCRIPT_DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" && pwd )"
# Run the PHP test suite
exec php "$SCRIPT_DIR/test-extractor.php" "$@"

View File

@@ -3,5 +3,8 @@ import { defineConfig } from "vitest/config";
export default defineConfig({
test: {
globals: true,
env: {
TEST: "true",
},
},
});