diff --git a/archive/folderview.plus-2026.07.24.01.txz b/archive/folderview.plus-2026.07.24.01.txz deleted file mode 100644 index 7fd9ce09..00000000 Binary files a/archive/folderview.plus-2026.07.24.01.txz and /dev/null differ diff --git a/archive/folderview.plus-2026.07.24.01.txz.sha256 b/archive/folderview.plus-2026.07.24.01.txz.sha256 deleted file mode 100644 index 14f5ce1d..00000000 --- a/archive/folderview.plus-2026.07.24.01.txz.sha256 +++ /dev/null @@ -1 +0,0 @@ -cb6bbc91c3469d08d6773f6fd6bd037ef0f78fd45b7b5c78664e49a02acb233e folderview.plus-2026.07.24.01.txz diff --git a/archive/folderview.plus-2026.07.28.06.txz b/archive/folderview.plus-2026.07.28.06.txz new file mode 100644 index 00000000..8d26a8f8 Binary files /dev/null and b/archive/folderview.plus-2026.07.28.06.txz differ diff --git a/archive/folderview.plus-2026.07.28.06.txz.sha256 b/archive/folderview.plus-2026.07.28.06.txz.sha256 new file mode 100644 index 00000000..a35d5c18 --- /dev/null +++ b/archive/folderview.plus-2026.07.28.06.txz.sha256 @@ -0,0 +1 @@ +e78b2e2f7d662058c6b3f603f7ab79783a3b0691f7a624be65180a37e389d3e2 folderview.plus-2026.07.28.06.txz diff --git a/docs/releases/2026.07.28.06.md b/docs/releases/2026.07.28.06.md new file mode 100644 index 00000000..c32bf617 --- /dev/null +++ b/docs/releases/2026.07.28.06.md @@ -0,0 +1,29 @@ +# FolderView Plus 2026.07.28.06 + +This reliability update restores folder interactions after the declarative +event security migration and removes false Storage and paths failures caused by +managed or retired installed runtime files. + +## Folder interactions + +- Fix: Route Docker and VM folder icon, name, and expand controls through one + shared delegated-action controller instead of the declarative compatibility + bridge. +- Fix: Bind each folder action once, remove the legacy action attribute during + row creation, and release the delegated handler when the page is unloaded. +- Diagnostics: Treat the shared folder-action controller as a required runtime + module so a missing or stale asset produces a specific bootstrap finding. + +## Runtime integrity + +- Diagnostics: Exclude the exact generated `icon-asset-pack.json` activation + record from unexpected executable-file findings while continuing to flag + every other unmanifested JSON, script, style, shell, or PHP file. +- Maintenance: Remove the retired native-organizer and Docker member-menu + scripts from the installed plugin directory during upgrades. + +## Regression coverage + +- Test: Exercise folder action decoration, single-dispatch behavior, teardown, + Docker and VM module ordering, managed runtime-file handling, obsolete-file + cleanup, browser fixtures, and runtime performance budgets. diff --git a/docs/sbom.cdx.json b/docs/sbom.cdx.json index e6e17a98..13eeaa71 100644 --- a/docs/sbom.cdx.json +++ b/docs/sbom.cdx.json @@ -6,7 +6,7 @@ "component": { "type": "application", "name": "FolderView Plus", - "version": "2026.07.28.05", + "version": "2026.07.28.06", "properties": [ { "name": "folderview-plus:runtime-dependencies", diff --git a/docs/security/csp-readiness.json b/docs/security/csp-readiness.json index 1f181eaa..2d559ddf 100644 --- a/docs/security/csp-readiness.json +++ b/docs/security/csp-readiness.json @@ -3,7 +3,7 @@ "mode": "report-only-shared-unraid-document", "scope": { "scannedRoot": "src/folderview.plus/usr/local/emhttp/plugins/folderview.plus", - "scannedFiles": 151, + "scannedFiles": 152, "exclusions": [ "third-party icon asset pack", "localization catalogs", @@ -20,7 +20,7 @@ "totals": { "inlineEventAttributes": 0, "inlineScriptBlocks": 22, - "externalScriptTags": 111, + "externalScriptTags": 113, "inlineStyleBlocks": 0, "inlineStyleAttributes": 207, "evalCalls": 0, @@ -96,7 +96,7 @@ "path": "src/folderview.plus/usr/local/emhttp/plugins/folderview.plus/folderview.plus.Docker.page", "inlineEventAttributes": 0, "inlineScriptBlocks": 5, - "externalScriptTags": 40, + "externalScriptTags": 41, "inlineStyleBlocks": 0, "inlineStyleAttributes": 0, "evalCalls": 0, @@ -108,7 +108,7 @@ "path": "src/folderview.plus/usr/local/emhttp/plugins/folderview.plus/folderview.plus.VMs.page", "inlineEventAttributes": 0, "inlineScriptBlocks": 2, - "externalScriptTags": 21, + "externalScriptTags": 22, "inlineStyleBlocks": 0, "inlineStyleAttributes": 0, "evalCalls": 0, diff --git a/folderview.plus.plg b/folderview.plus.plg index bdc07d94..5d201706 100644 --- a/folderview.plus.plg +++ b/folderview.plus.plg @@ -6,9 +6,9 @@ - - - + + + @@ -18,6 +18,31 @@ +###2026.07.28.06 +# FolderView Plus 2026.07.28.06 +This reliability update restores folder interactions after the declarative +event security migration and removes false Storage and paths failures caused by +managed or retired installed runtime files. +## Folder interactions +- Fix: Route Docker and VM folder icon, name, and expand controls through one + shared delegated-action controller instead of the declarative compatibility + bridge. +- Fix: Bind each folder action once, remove the legacy action attribute during + row creation, and release the delegated handler when the page is unloaded. +- Diagnostics: Treat the shared folder-action controller as a required runtime + module so a missing or stale asset produces a specific bootstrap finding. +## Runtime integrity +- Diagnostics: Exclude the exact generated `icon-asset-pack.json` activation + record from unexpected executable-file findings while continuing to flag + every other unmanifested JSON, script, style, shell, or PHP file. +- Maintenance: Remove the retired native-organizer and Docker member-menu + scripts from the installed plugin directory during upgrades. +## Regression coverage +- Test: Exercise folder action decoration, single-dispatch behavior, teardown, + Docker and VM module ordering, managed runtime-file handling, obsolete-file + cleanup, browser fixtures, and runtime performance budgets. + + ###2026.07.28.05 # FolderView Plus 2026.07.28.05 This security update adds continuous runtime assurance, replay-resistant @@ -8032,6 +8057,8 @@ This dev-channel synchronization packages the cross-browser Diagnostics correcti mkdir -p /boot/config/plugins/&name;/styles mkdir -p /boot/config/plugins/&name;/scripts find /boot/config/plugins/&name; -maxdepth 1 -type f -name '&name;-[0-9]*.txz' ! -name '&name;-&version;.txz' -delete + install_stage="obsolete runtime cleanup" + rm -f "&plugdir;/scripts/folderviewplus.native-organizer.js" "&plugdir;/scripts/docker.member-menu.js" install_stage="icon pack activation" FVPLUS_ICON_PACK_STATUS_FILE="${icon_status}" /bin/bash "&plugdir;/scripts/install_icon_asset_pack.sh" "&iconPackVersion;" "&iconPackSha256;" install_stage="scheduled backup registration" diff --git a/src/folderview.plus/usr/local/emhttp/plugins/folderview.plus/folderview.plus.Docker.page b/src/folderview.plus/usr/local/emhttp/plugins/folderview.plus/folderview.plus.Docker.page index 21db9535..761e77eb 100644 --- a/src/folderview.plus/usr/local/emhttp/plugins/folderview.plus/folderview.plus.Docker.page +++ b/src/folderview.plus/usr/local/emhttp/plugins/folderview.plus/folderview.plus.Docker.page @@ -78,6 +78,7 @@ $fvplusRuntimePreflightHasFatal = runtimePreflightHasFatal($fvplusRuntimePreflig + diff --git a/src/folderview.plus/usr/local/emhttp/plugins/folderview.plus/folderview.plus.VMs.page b/src/folderview.plus/usr/local/emhttp/plugins/folderview.plus/folderview.plus.VMs.page index 38e26df0..b1bcebeb 100644 --- a/src/folderview.plus/usr/local/emhttp/plugins/folderview.plus/folderview.plus.VMs.page +++ b/src/folderview.plus/usr/local/emhttp/plugins/folderview.plus/folderview.plus.VMs.page @@ -65,6 +65,7 @@ $fvplusRuntimePreflightHasFatal = runtimePreflightHasFatal($fvplusRuntimePreflig + diff --git a/src/folderview.plus/usr/local/emhttp/plugins/folderview.plus/scripts/docker.js b/src/folderview.plus/usr/local/emhttp/plugins/folderview.plus/scripts/docker.js index 6c03b9f9..c999a70b 100644 --- a/src/folderview.plus/usr/local/emhttp/plugins/folderview.plus/scripts/docker.js +++ b/src/folderview.plus/usr/local/emhttp/plugins/folderview.plus/scripts/docker.js @@ -388,6 +388,12 @@ if (!runtimeFolderOrdering || typeof runtimeFolderOrdering.createOrderCursor !== } else { setDockerFatalBannerModuleStatus('runtime.folder-ordering.js', 'ok', 'folder ordering contract ready'); } +if (typeof dockerRuntimeShared.createFolderRowActionsController !== 'function') { + dockerBootstrapMissingModules.push('folder.runtime.row-actions.js'); + setDockerFatalBannerModuleStatus('folder.runtime.row-actions.js', 'missing', 'folder row action lifecycle unavailable'); +} else { + setDockerFatalBannerModuleStatus('folder.runtime.row-actions.js', 'ok', 'folder row actions ready'); +} if ( !window.FolderViewDockerRuntimeShared || typeof window.FolderViewDockerRuntimeShared.createAsyncActionBoundary !== 'function' @@ -5973,6 +5979,7 @@ const createFolder = (folder, id, positionInMainOrder, liveOrderArray, container const $createdFolderRow = $('#docker_list > tr.folder[data-fv-folder-id]') .filter((_, element) => String(element.getAttribute('data-fv-folder-id') || '') === id) .first(); + dockerFolderRowActionsController.decorate($createdFolderRow, id); $createdFolderRow .attr('data-folder-depth', String(safeDepth)) .find('.folder-name-sub') @@ -6982,7 +6989,18 @@ const dropDownButton = (id, persistState = true) => { hierarchyApi.dropDownButton(id, persistState); } }; - +const dockerFolderRowActionsController = dockerRuntimeShared.createFolderRowActionsController({ + document, + $, + namespace: 'fvDockerFolderRowAction', + actionAttribute: 'data-fv-docker-folder-action', + handlers: { + toggle: (id) => dropDownButton(id), + edit: (id) => editFolder(id), + context: (id) => addDockerFolderContext(id) + } +}); +const bindDockerFolderRowActions = () => dockerFolderRowActionsController.bind(); /** * Removie the folder * @param {string} id the id of the folder @@ -8733,7 +8751,7 @@ window.editFolder = editFolder; window.forceUpdateFolder = forceUpdateFolder; window.updateFolder = updateFolder; window.createFolderBtn = createFolderBtn; - +bindDockerFolderRowActions(); // This is needed because unraid don't like the folder and the number are set incorrectly, this intercept the request and change the numbers to make the order appear right, this is important for the autostart and to draw the folders $.ajaxPrefilter((options, originalOptions, jqXHR) => { if (options.url === "/plugins/dynamix.docker.manager/include/UserPrefs.php") { @@ -8810,6 +8828,7 @@ window.addEventListener('pagehide', () => { clearTimeout(dockerRuntimePrivacyServerReconcileTimer); clearTimeout(dockerSupportBundlePageSnapshotWriteTimer); clearTimeout(folderViewPlusDockerStartOrderSyncTimer); + dockerFolderRowActionsController.destroy(); dockerRuntimeResizerObserver?.disconnect?.(); dockerDeferredPreviewController.destroy(); dockerProviderHealthController?.dispose?.(); diff --git a/src/folderview.plus/usr/local/emhttp/plugins/folderview.plus/scripts/folder.runtime.row-actions.js b/src/folderview.plus/usr/local/emhttp/plugins/folderview.plus/scripts/folder.runtime.row-actions.js new file mode 100644 index 00000000..3a253da7 --- /dev/null +++ b/src/folderview.plus/usr/local/emhttp/plugins/folderview.plus/scripts/folder.runtime.row-actions.js @@ -0,0 +1,81 @@ +(function(root, factory) { + if (typeof module === 'object' && module.exports) { + module.exports = factory(); + return; + } + const api = factory(); + if (root.FolderViewDockerRuntimeShared && 'object' === typeof root.FolderViewDockerRuntimeShared) { + root.FolderViewDockerRuntimeShared.createFolderRowActionsController = api.createController; + } +}(typeof globalThis !== 'undefined' ? globalThis : this, function() { + const createController = (deps = {}) => { + const jq = deps.$; + const doc = deps.document || (typeof document !== 'undefined' ? document : null); + const namespace = String(deps.namespace || '').replace(/[^A-Za-z0-9]/g, ''); + const actionAttribute = String(deps.actionAttribute || '').trim(); + const handlers = deps.handlers && typeof deps.handlers === 'object' ? deps.handlers : {}; + if (!jq || !doc || !namespace || !/^data-fv-[a-z0-9-]+$/.test(actionAttribute)) { + throw new Error('Folder row action controller dependencies are invalid.'); + } + + const selector = `[${actionAttribute}]`; + const eventName = `click.${namespace}`; + const decorateTarget = ($row, targetSelector, action, first = false) => { + const $target = first ? $row.find(targetSelector).first() : $row.find(targetSelector); + $target + .removeAttr('data-fv-onclick') + .attr({ + [actionAttribute]: action, + 'data-fv-folder-id': String($row.attr('data-fv-folder-id') || '') + }); + return $target; + }; + const decorate = ($row, folderId) => { + if (!$row || !$row.length) { + return; + } + const id = String(folderId || '').trim(); + if (!id) { + return; + } + $row.attr('data-fv-folder-id', id); + decorateTarget($row, '.folder-hand', 'context', true); + decorateTarget($row, '.folder-appname', 'edit', true); + decorateTarget($row, '.folder-dropdown', 'toggle', true).attr('type', 'button'); + }; + const dispatch = (element, event) => { + const action = String(element?.getAttribute?.(actionAttribute) || '').trim(); + const id = String(element?.getAttribute?.('data-fv-folder-id') || '').trim(); + const handler = handlers[action]; + if (!action || !id || typeof handler !== 'function') { + return false; + } + event?.preventDefault?.(); + handler(id, event); + return true; + }; + const bind = () => { + jq(doc) + .off(eventName, selector) + .on(eventName, selector, function(event) { + dispatch(this, event); + }); + }; + const destroy = () => { + jq(doc).off(eventName, selector); + }; + + return Object.freeze({ + bind, + decorate, + destroy, + dispatch, + actionAttribute, + selector + }); + }; + + return Object.freeze({ + createController + }); +})); diff --git a/src/folderview.plus/usr/local/emhttp/plugins/folderview.plus/scripts/vm.js b/src/folderview.plus/usr/local/emhttp/plugins/folderview.plus/scripts/vm.js index 151263a7..094ad7ab 100644 --- a/src/folderview.plus/usr/local/emhttp/plugins/folderview.plus/scripts/vm.js +++ b/src/folderview.plus/usr/local/emhttp/plugins/folderview.plus/scripts/vm.js @@ -341,6 +341,12 @@ if (!runtimeFolderOrdering || typeof runtimeFolderOrdering.createOrderCursor !== } else { setVmFatalBannerModuleStatus('runtime.folder-ordering.js', 'ok', 'folder ordering contract ready'); } +if (typeof runtimeShared.createFolderRowActionsController !== 'function') { + vmBootstrapMissingModules.push('folder.runtime.row-actions.js'); + setVmFatalBannerModuleStatus('folder.runtime.row-actions.js', 'missing', 'folder row action lifecycle unavailable'); +} else { + setVmFatalBannerModuleStatus('folder.runtime.row-actions.js', 'ok', 'folder row actions ready'); +} if ( window.FolderViewPlusVmRuntimeLifecycleModuleLoaded !== true || !vmLifecycleModule @@ -1917,6 +1923,8 @@ const createFolder = (folder, id, position, order, vmInfo, foldersDone, matchCac } else { $('#kvm_list > tr.sortable').eq(position - 1).next().after($(fld)); } + const $createdFolderRow = $(`tr.folder-id-${id}`).first(); + vmFolderRowActionsController.decorate($createdFolderRow, id); const safeDepth = Math.max(0, Math.min(8, Number(depthLevel) || 0)); const depthIndentPx = safeDepth * 20; $(`tr.folder-id-${id}`) @@ -2261,6 +2269,18 @@ const dropDownButton = (id, persistState = true) => { scheduleVmZebraRefresh(); folderEvents.dispatchEvent(new CustomEvent('vm-post-folder-expansion', {detail: { id }})); }; +const vmFolderRowActionsController = runtimeShared.createFolderRowActionsController({ + document, + $, + namespace: 'fvVmFolderRowAction', + actionAttribute: 'data-fv-vm-folder-action', + handlers: { + toggle: (id) => dropDownButton(id), + edit: (id) => editFolder(id), + context: (id) => addVMFolderContext(id) + } +}); +const bindVmFolderRowActions = () => vmFolderRowActionsController.bind(); const readVmFolderContainerNames = (containers) => { if (Array.isArray(containers)) { @@ -3927,6 +3947,7 @@ window.addVMFolderContext = addVMFolderContext; window.dropDownButton = dropDownButton; window.editFolder = editFolder; window.createFolderBtn = createFolderBtn; +bindVmFolderRowActions(); $.ajaxPrefilter((options, originalOptions, jqXHR) => { @@ -3974,6 +3995,7 @@ window.addEventListener('pagehide', () => { clearTimeout(queuedLoadlistTimer); clearTimeout(vmRuntimeWidthReflowTimer); clearTimeout(vmZebraRefreshTimer); + vmFolderRowActionsController.destroy(); vmNativeDetailRowObserver?.disconnect?.(); if (vmNativeToggleClickHost instanceof HTMLTableElement) { vmNativeToggleClickHost.removeEventListener('click', handleVmNativeToggleClick, true); diff --git a/src/folderview.plus/usr/local/emhttp/plugins/folderview.plus/server/lib.security.php b/src/folderview.plus/usr/local/emhttp/plugins/folderview.plus/server/lib.security.php index e6a728e3..4f9c4ab1 100644 --- a/src/folderview.plus/usr/local/emhttp/plugins/folderview.plus/server/lib.security.php +++ b/src/folderview.plus/usr/local/emhttp/plugins/folderview.plus/server/lib.security.php @@ -477,6 +477,12 @@ function fvplus_runtime_integrity_should_track(string $relativePath): bool return (bool)preg_match('/\.(?:php|js|sh|css|json)$/i', $relativePath); } +function fvplus_runtime_integrity_is_managed_runtime_file(string $relativePath): bool +{ + $relativePath = ltrim(str_replace('\\', '/', $relativePath), '/'); + return $relativePath === 'icon-asset-pack.json'; +} + function fvplus_get_runtime_integrity_snapshot(string $privacyMode = 'sanitized'): array { global $sourceDir; @@ -582,7 +588,11 @@ function fvplus_get_runtime_integrity_snapshot(string $privacyMode = 'sanitized' } } foreach (array_values(array_unique($candidates)) as $relative) { - if (fvplus_runtime_integrity_should_track($relative) && !isset($expected[$relative])) { + if ( + fvplus_runtime_integrity_should_track($relative) + && !fvplus_runtime_integrity_is_managed_runtime_file($relative) + && !isset($expected[$relative]) + ) { $unexpectedCount++; $findings[] = ['kind' => 'unexpected', 'path' => $relative]; } diff --git a/tests/deprecation-registry.test.mjs b/tests/deprecation-registry.test.mjs index dab72866..d3f248c1 100644 --- a/tests/deprecation-registry.test.mjs +++ b/tests/deprecation-registry.test.mjs @@ -21,7 +21,12 @@ test('deprecation registry enforces compatibility and removed-token contracts', test('removed native organizer integration leaves only bounded legacy browser-state cleanup', () => { const pluginRoot = path.join(repoRoot, 'src/folderview.plus/usr/local/emhttp/plugins/folderview.plus'); const settingsRuntime = fs.readFileSync(path.join(pluginRoot, 'scripts/folderviewplus.js'), 'utf8'); + const pluginManifest = fs.readFileSync(path.join(repoRoot, 'folderview.plus.plg'), 'utf8'); assert.equal(fs.existsSync(path.join(pluginRoot, 'scripts/folderviewplus.native-organizer.js')), false); assert.match(settingsRuntime, /const REMOVED_SETTINGS_STORAGE_KEYS = Object\.freeze\(\[\s*'fv\.native\.organizer\.status\.v1'\s*\]\);/); assert.match(settingsRuntime, /removeSettingsStorage\(storageKey, \{ delayMs: 0 \}\);/); + assert.match( + pluginManifest, + /rm -f "&plugdir;\/scripts\/folderviewplus\.native-organizer\.js" "&plugdir;\/scripts\/docker\.member-menu\.js"/ + ); }); diff --git a/tests/docker-runtime-shared-architecture.test.mjs b/tests/docker-runtime-shared-architecture.test.mjs index f7050398..31453f4d 100644 --- a/tests/docker-runtime-shared-architecture.test.mjs +++ b/tests/docker-runtime-shared-architecture.test.mjs @@ -8,6 +8,7 @@ const read = (relativePath) => fs.readFileSync(path.join(repoRoot, relativePath) const dockerPage = read('src/folderview.plus/usr/local/emhttp/plugins/folderview.plus/folderview.plus.Docker.page'); const folderContractJs = read('src/folderview.plus/usr/local/emhttp/plugins/folderview.plus/scripts/folderviewplus.folder-contract.js'); +const folderRowActionsJs = read('src/folderview.plus/usr/local/emhttp/plugins/folderview.plus/scripts/folder.runtime.row-actions.js'); const runtimeHostAdapterJs = read('src/folderview.plus/usr/local/emhttp/plugins/folderview.plus/scripts/runtime.host-adapter.js'); const dockerSharedJs = read('src/folderview.plus/usr/local/emhttp/plugins/folderview.plus/scripts/docker.runtime.shared.js'); const dockerModulesJs = read('src/folderview.plus/usr/local/emhttp/plugins/folderview.plus/scripts/docker.modules.js'); @@ -43,6 +44,7 @@ test('docker runtime page loads shared runtime module before docker modules/runt const snapshotIndex = dockerPage.indexOf('/plugins/folderview.plus/scripts/folderviewplus.runtime-snapshot.js'); const sharedIndex = dockerPage.indexOf('/plugins/folderview.plus/scripts/docker.runtime.shared.js'); const stateObserverIndex = dockerPage.indexOf('/plugins/folderview.plus/scripts/folder.runtime.state-observers.js'); + const rowActionsIndex = dockerPage.indexOf('/plugins/folderview.plus/scripts/folder.runtime.row-actions.js'); const hostAdapterIndex = dockerPage.indexOf('/plugins/folderview.plus/scripts/runtime.host-adapter.js'); const orderingIndex = dockerPage.indexOf('/plugins/folderview.plus/scripts/runtime.folder-ordering.js'); const modulesIndex = dockerPage.indexOf('/plugins/folderview.plus/scripts/docker.modules.js'); @@ -65,6 +67,7 @@ test('docker runtime page loads shared runtime module before docker modules/runt assert.ok(snapshotIndex >= 0, 'runtime snapshot client include is missing'); assert.ok(sharedIndex >= 0, 'shared runtime script include is missing'); assert.ok(stateObserverIndex >= 0, 'runtime state observer script include is missing'); + assert.ok(rowActionsIndex >= 0, 'folder row action script include is missing'); assert.ok(hostAdapterIndex >= 0, 'shared host adapter script include is missing'); assert.ok(orderingIndex >= 0, 'shared folder ordering script include is missing'); assert.ok(modulesIndex >= 0, 'docker modules script include is missing'); @@ -105,6 +108,7 @@ test('docker runtime page loads shared runtime module before docker modules/runt assert.ok(reconcileIndex < commandViewIndex, 'docker reconcile helpers must load before docker.runtime.command-view.js'); assert.ok(commandViewIndex < runtimeIndex, 'docker command-view helpers must load before docker.js'); assert.ok(stateObserverIndex < runtimeIndex, 'runtime state observer module must load before docker.js'); + assert.ok(stateObserverIndex < rowActionsIndex && rowActionsIndex < runtimeIndex, 'folder row actions must load after state observers and before docker.js'); assert.ok(sharedIndex < runtimeIndex, 'shared runtime must load before docker.js'); assert.ok(themeTokensCssIndex < sharedCssIndex, 'theme token stylesheet must load before runtime.shared.css'); assert.ok(sharedCssIndex < commandViewCssIndex, 'shared runtime stylesheet must load before docker.command-view.css'); @@ -277,6 +281,18 @@ test('docker runtime consumes shared state store and guarded async action wrappe assert.match(dockerJs, /window\.forceUpdateFolder = forceUpdateFolder;/); assert.match(dockerJs, /window\.updateFolder = updateFolder;/); assert.match(dockerJs, /window\.createFolderBtn = createFolderBtn;/); + assert.match(dockerJs, /dockerBootstrapMissingModules\.push\('folder\.runtime\.row-actions\.js'\)/); + assert.match(dockerJs, /const dockerFolderRowActionsController = dockerRuntimeShared\.createFolderRowActionsController\(\{/); + assert.match(dockerJs, /actionAttribute: 'data-fv-docker-folder-action'/); + assert.match(dockerJs, /toggle: \(id\) => dropDownButton\(id\)/); + assert.match(dockerJs, /edit: \(id\) => editFolder\(id\)/); + assert.match(dockerJs, /context: \(id\) => addDockerFolderContext\(id\)/); + assert.match(dockerJs, /dockerFolderRowActionsController\.decorate\(\$createdFolderRow, id\);/); + assert.match(dockerJs, /bindDockerFolderRowActions\(\);/); + assert.match(dockerJs, /dockerFolderRowActionsController\.destroy\(\);/); + assert.match(folderRowActionsJs, /\.removeAttr\('data-fv-onclick'\)/); + assert.match(folderRowActionsJs, /\.off\(eventName, selector\)[\s\S]*\.on\(eventName, selector/); + assert.match(folderRowActionsJs, /root\.FolderViewDockerRuntimeShared\.createFolderRowActionsController = api\.createController/); }); test('docker command-view stylesheet only hides the host table when the isolated command module is mounted', () => { diff --git a/tests/folder-row-actions.test.mjs b/tests/folder-row-actions.test.mjs new file mode 100644 index 00000000..49ab2515 --- /dev/null +++ b/tests/folder-row-actions.test.mjs @@ -0,0 +1,118 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import path from 'node:path'; +import { createRequire } from 'node:module'; + +const require = createRequire(import.meta.url); +const modulePath = path.resolve( + process.cwd(), + 'src/folderview.plus/usr/local/emhttp/plugins/folderview.plus/scripts/folder.runtime.row-actions.js' +); +const rowActions = require(modulePath); + +const createTarget = () => { + const attributes = new Map([['data-fv-onclick', 'legacyHandler()']]); + const target = { + length: 1, + first: () => target, + removeAttr(name) { + attributes.delete(name); + return target; + }, + attr(nameOrValues, value) { + if (typeof nameOrValues === 'string' && value === undefined) { + return attributes.get(nameOrValues); + } + const values = typeof nameOrValues === 'string' + ? { [nameOrValues]: value } + : nameOrValues; + Object.entries(values).forEach(([name, nextValue]) => attributes.set(name, String(nextValue))); + return target; + }, + attributes + }; + return target; +}; + +test('folder row actions replace declarative handlers and dispatch each action once', () => { + const document = {}; + const delegated = new Map(); + const eventRoot = { + off(eventName, selector) { + delegated.delete(`${eventName}|${selector}`); + return eventRoot; + }, + on(eventName, selector, handler) { + delegated.set(`${eventName}|${selector}`, handler); + return eventRoot; + } + }; + const $ = (value) => { + assert.equal(value, document); + return eventRoot; + }; + const targets = { + '.folder-hand': createTarget(), + '.folder-appname': createTarget(), + '.folder-dropdown': createTarget() + }; + const rowAttributes = new Map(); + const row = { + length: 1, + attr(name, value) { + if (value === undefined) { + return rowAttributes.get(name); + } + rowAttributes.set(name, String(value)); + return row; + }, + find(selector) { + return targets[selector]; + } + }; + const calls = []; + const controller = rowActions.createController({ + document, + $, + namespace: 'fvTestFolderRowAction', + actionAttribute: 'data-fv-test-folder-action', + handlers: { + context: (id) => calls.push(`context:${id}`), + edit: (id) => calls.push(`edit:${id}`), + toggle: (id) => calls.push(`toggle:${id}`) + } + }); + + controller.decorate(row, 'folder-1'); + for (const [selector, action] of Object.entries({ + '.folder-hand': 'context', + '.folder-appname': 'edit', + '.folder-dropdown': 'toggle' + })) { + assert.equal(targets[selector].attributes.has('data-fv-onclick'), false); + assert.equal(targets[selector].attr('data-fv-test-folder-action'), action); + assert.equal(targets[selector].attr('data-fv-folder-id'), 'folder-1'); + } + assert.equal(targets['.folder-dropdown'].attr('type'), 'button'); + + controller.bind(); + const delegatedHandler = delegated.get('click.fvTestFolderRowAction|[data-fv-test-folder-action]'); + assert.equal(typeof delegatedHandler, 'function'); + let prevented = 0; + for (const selector of Object.keys(targets)) { + delegatedHandler.call({ + getAttribute(name) { + return targets[selector].attr(name) || ''; + } + }, { + preventDefault() { + prevented += 1; + } + }); + } + assert.deepEqual(calls, ['context:folder-1', 'edit:folder-1', 'toggle:folder-1']); + assert.equal(prevented, 3); + + controller.destroy(); + assert.equal(delegated.size, 0); +}); diff --git a/tests/security-update-3.test.mjs b/tests/security-update-3.test.mjs index 66c96d64..a4480de8 100644 --- a/tests/security-update-3.test.mjs +++ b/tests/security-update-3.test.mjs @@ -176,6 +176,8 @@ test('packaged runtime manifest excludes unrelated assets and detects modified e manifest.files.map((entry) => entry.path), ['Folder.page', 'scripts/test.js', 'server/test.php'] ); + fs.writeFileSync(path.join(temp, 'icon-asset-pack.json'), '{"managed":true}'); + fs.writeFileSync(path.join(temp, 'scripts/obsolete.js'), 'window.obsolete = true;'); fs.appendFileSync(path.join(temp, 'server/test.php'), '\n// modified'); const snapshot = runPhp(` require_once(${phpString(libPath)}); @@ -187,7 +189,10 @@ test('packaged runtime manifest excludes unrelated assets and detects modified e }); assert.equal(snapshot.status, 'critical'); assert.ok(snapshot.modifiedCount >= 1); + assert.equal(snapshot.unexpectedCount, 1); assert.ok(snapshot.findings.some((finding) => finding.kind === 'modified' && finding.path === 'server/test.php')); + assert.ok(snapshot.findings.some((finding) => finding.kind === 'unexpected' && finding.path === 'scripts/obsolete.js')); + assert.ok(!snapshot.findings.some((finding) => finding.path === 'icon-asset-pack.json')); } finally { fs.rmSync(temp, { recursive: true, force: true }); } diff --git a/tests/vm-runtime-shared-architecture.test.mjs b/tests/vm-runtime-shared-architecture.test.mjs index 4143e1e9..deadf26a 100644 --- a/tests/vm-runtime-shared-architecture.test.mjs +++ b/tests/vm-runtime-shared-architecture.test.mjs @@ -8,6 +8,7 @@ const read = (relativePath) => fs.readFileSync(path.join(repoRoot, relativePath) const vmPage = read('src/folderview.plus/usr/local/emhttp/plugins/folderview.plus/folderview.plus.VMs.page'); const vmJs = read('src/folderview.plus/usr/local/emhttp/plugins/folderview.plus/scripts/vm.js'); +const folderRowActionsJs = read('src/folderview.plus/usr/local/emhttp/plugins/folderview.plus/scripts/folder.runtime.row-actions.js'); const runtimeHostAdapterJs = read('src/folderview.plus/usr/local/emhttp/plugins/folderview.plus/scripts/runtime.host-adapter.js'); const vmLifecycleJs = read('src/folderview.plus/usr/local/emhttp/plugins/folderview.plus/scripts/vm.runtime.lifecycle.js'); const vmCss = read('src/folderview.plus/usr/local/emhttp/plugins/folderview.plus/styles/vm.css'); @@ -19,6 +20,7 @@ test('vm runtime page loads shared runtime module before vm runtime script', () const snapshotIndex = vmPage.indexOf('/plugins/folderview.plus/scripts/folderviewplus.runtime-snapshot.js'); const sharedIndex = vmPage.indexOf('/plugins/folderview.plus/scripts/docker.runtime.shared.js'); const stateObserverIndex = vmPage.indexOf('/plugins/folderview.plus/scripts/folder.runtime.state-observers.js'); + const rowActionsIndex = vmPage.indexOf('/plugins/folderview.plus/scripts/folder.runtime.row-actions.js'); const hostAdapterIndex = vmPage.indexOf('/plugins/folderview.plus/scripts/runtime.host-adapter.js'); const orderingIndex = vmPage.indexOf('/plugins/folderview.plus/scripts/runtime.folder-ordering.js'); const lifecycleIndex = vmPage.indexOf('/plugins/folderview.plus/scripts/vm.runtime.lifecycle.js'); @@ -31,6 +33,7 @@ test('vm runtime page loads shared runtime module before vm runtime script', () assert.ok(snapshotIndex >= 0, 'runtime snapshot client include missing from VMs page'); assert.ok(sharedIndex >= 0, 'shared runtime include missing from VMs page'); assert.ok(stateObserverIndex >= 0, 'runtime state observer include missing from VMs page'); + assert.ok(rowActionsIndex >= 0, 'folder row action include missing from VMs page'); assert.ok(hostAdapterIndex >= 0, 'shared host adapter include missing from VMs page'); assert.ok(orderingIndex >= 0, 'shared folder ordering include missing from VMs page'); assert.ok(lifecycleIndex >= 0, 'VM lifecycle coordinator include missing from VMs page'); @@ -46,6 +49,7 @@ test('vm runtime page loads shared runtime module before vm runtime script', () assert.ok(snapshotIndex < runtimeIndex, 'runtime snapshot client must load before vm.js'); assert.ok(contractIndex < sharedIndex, 'shared contract must load before shared runtime on VMs page'); assert.ok(stateObserverIndex < runtimeIndex, 'runtime state observer must load before vm.js'); + assert.ok(stateObserverIndex < rowActionsIndex && rowActionsIndex < lifecycleIndex, 'folder row actions must load after state observers and before VM lifecycle/runtime'); assert.ok(hostAdapterIndex < runtimeIndex, 'shared host adapter must load before vm.js'); assert.ok(hostAdapterIndex < orderingIndex && orderingIndex < runtimeIndex, 'shared ordering must load after the host adapter and before vm.js'); assert.ok(lifecycleIndex < runtimeIndex, 'VM lifecycle coordinator must load before vm.js'); @@ -109,6 +113,18 @@ test('vm runtime consumes shared state/perf/action modules and exposes telemetry assert.match(vmJs, /window\.dropDownButton = dropDownButton;/); assert.match(vmJs, /window\.editFolder = editFolder;/); assert.match(vmJs, /window\.createFolderBtn = createFolderBtn;/); + assert.match(vmJs, /vmBootstrapMissingModules\.push\('folder\.runtime\.row-actions\.js'\)/); + assert.match(vmJs, /const vmFolderRowActionsController = runtimeShared\.createFolderRowActionsController\(\{/); + assert.match(vmJs, /actionAttribute: 'data-fv-vm-folder-action'/); + assert.match(vmJs, /toggle: \(id\) => dropDownButton\(id\)/); + assert.match(vmJs, /edit: \(id\) => editFolder\(id\)/); + assert.match(vmJs, /context: \(id\) => addVMFolderContext\(id\)/); + assert.match(vmJs, /vmFolderRowActionsController\.decorate\(\$createdFolderRow, id\);/); + assert.match(vmJs, /bindVmFolderRowActions\(\);/); + assert.match(vmJs, /vmFolderRowActionsController\.destroy\(\);/); + assert.match(folderRowActionsJs, /\.removeAttr\('data-fv-onclick'\)/); + assert.match(folderRowActionsJs, /\.off\(eventName, selector\)[\s\S]*\.on\(eventName, selector/); + assert.match(folderRowActionsJs, /root\.FolderViewDockerRuntimeShared\.createFolderRowActionsController = api\.createController/); }); test('vm CSS keeps VM-specific gutter tokens while shared stylesheet owns shared preview and dropdown rules', () => {