mirror of
https://github.com/unraid/api.git
synced 2026-01-04 15:39:52 -06:00
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:
@@ -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",
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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" : ""}`
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
369
plugin/tests/test-extractor.php
Executable 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'file"') !== false ||
|
||||
strpos($output, "special'file"") !== false ||
|
||||
strpos($output, "special'file"") === 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
16
plugin/tests/test-extractor.sh
Executable 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" "$@"
|
||||
@@ -3,5 +3,8 @@ import { defineConfig } from "vitest/config";
|
||||
export default defineConfig({
|
||||
test: {
|
||||
globals: true,
|
||||
env: {
|
||||
TEST: "true",
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user