diff --git a/archive/folderview.plus-2026.08.11.13.txz b/archive/folderview.plus-2026.08.11.13.txz deleted file mode 100644 index 79a3b3a9..00000000 Binary files a/archive/folderview.plus-2026.08.11.13.txz and /dev/null differ diff --git a/archive/folderview.plus-2026.08.11.13.txz.sha256 b/archive/folderview.plus-2026.08.11.13.txz.sha256 deleted file mode 100644 index 995a5728..00000000 --- a/archive/folderview.plus-2026.08.11.13.txz.sha256 +++ /dev/null @@ -1 +0,0 @@ -f9087c1327a53b23660cc37ee168e2854c5eb86193eb3ccbe106b11794fbd88c folderview.plus-2026.08.11.13.txz diff --git a/archive/folderview.plus-2026.08.11.14.txz b/archive/folderview.plus-2026.08.11.14.txz deleted file mode 100644 index c8fdab8f..00000000 Binary files a/archive/folderview.plus-2026.08.11.14.txz and /dev/null differ diff --git a/archive/folderview.plus-2026.08.11.14.txz.sha256 b/archive/folderview.plus-2026.08.11.14.txz.sha256 deleted file mode 100644 index c4e086fd..00000000 --- a/archive/folderview.plus-2026.08.11.14.txz.sha256 +++ /dev/null @@ -1 +0,0 @@ -24c6878599b11c4c605668f6a96a82b84d64246bc558acd208fd5ecc1d3a3f84 folderview.plus-2026.08.11.14.txz diff --git a/archive/folderview.plus-2026.08.12.01.txz b/archive/folderview.plus-2026.08.12.01.txz deleted file mode 100644 index 4e49d7c3..00000000 Binary files a/archive/folderview.plus-2026.08.12.01.txz and /dev/null differ diff --git a/archive/folderview.plus-2026.08.12.01.txz.sha256 b/archive/folderview.plus-2026.08.12.01.txz.sha256 deleted file mode 100644 index de21b076..00000000 --- a/archive/folderview.plus-2026.08.12.01.txz.sha256 +++ /dev/null @@ -1 +0,0 @@ -89803ec73f426b65172c40eaadce4aa94bd50f41ee1a201a86d2430c6112a9e5 folderview.plus-2026.08.12.01.txz diff --git a/archive/folderview.plus-2026.08.20.02.txz b/archive/folderview.plus-2026.08.20.02.txz deleted file mode 100644 index c5748d4b..00000000 Binary files a/archive/folderview.plus-2026.08.20.02.txz and /dev/null differ diff --git a/archive/folderview.plus-2026.08.20.02.txz.sha256 b/archive/folderview.plus-2026.08.20.02.txz.sha256 deleted file mode 100644 index 6eb1830b..00000000 --- a/archive/folderview.plus-2026.08.20.02.txz.sha256 +++ /dev/null @@ -1 +0,0 @@ -f0b2143536d393b8c641b9ac154795230aae5a3eb3ff319facbfc13bdb871b46 folderview.plus-2026.08.20.02.txz diff --git a/archive/folderview.plus-2026.08.24.01.txz b/archive/folderview.plus-2026.08.24.01.txz new file mode 100644 index 00000000..d0031575 Binary files /dev/null and b/archive/folderview.plus-2026.08.24.01.txz differ diff --git a/archive/folderview.plus-2026.08.24.01.txz.sha256 b/archive/folderview.plus-2026.08.24.01.txz.sha256 new file mode 100644 index 00000000..f2d29528 --- /dev/null +++ b/archive/folderview.plus-2026.08.24.01.txz.sha256 @@ -0,0 +1 @@ +abb24d180ae0b29aa30e46c9a9bb22550e53a930f6d2cda60e071dcea093d574 folderview.plus-2026.08.24.01.txz diff --git a/archive/folderview.plus-2026.08.24.02.txz b/archive/folderview.plus-2026.08.24.02.txz new file mode 100644 index 00000000..34a75111 Binary files /dev/null and b/archive/folderview.plus-2026.08.24.02.txz differ diff --git a/archive/folderview.plus-2026.08.24.02.txz.sha256 b/archive/folderview.plus-2026.08.24.02.txz.sha256 new file mode 100644 index 00000000..b1b42b19 --- /dev/null +++ b/archive/folderview.plus-2026.08.24.02.txz.sha256 @@ -0,0 +1 @@ +665ad6a547d62a834faf4c329e4f7df6b5767500701e992cd63b55db737a1d23 folderview.plus-2026.08.24.02.txz diff --git a/archive/folderview.plus-2026.08.24.03.txz b/archive/folderview.plus-2026.08.24.03.txz new file mode 100644 index 00000000..7c759e3e Binary files /dev/null and b/archive/folderview.plus-2026.08.24.03.txz differ diff --git a/archive/folderview.plus-2026.08.24.03.txz.sha256 b/archive/folderview.plus-2026.08.24.03.txz.sha256 new file mode 100644 index 00000000..7fc7c9a8 --- /dev/null +++ b/archive/folderview.plus-2026.08.24.03.txz.sha256 @@ -0,0 +1 @@ +8e9a71b2985f065f9f21416dfce6dcb10cd72c1e24e34a00edf5508734b3f1f4 folderview.plus-2026.08.24.03.txz diff --git a/archive/folderview.plus-2026.08.24.04.txz b/archive/folderview.plus-2026.08.24.04.txz new file mode 100644 index 00000000..022cd5ca Binary files /dev/null and b/archive/folderview.plus-2026.08.24.04.txz differ diff --git a/archive/folderview.plus-2026.08.24.04.txz.sha256 b/archive/folderview.plus-2026.08.24.04.txz.sha256 new file mode 100644 index 00000000..aad7e493 --- /dev/null +++ b/archive/folderview.plus-2026.08.24.04.txz.sha256 @@ -0,0 +1 @@ +9f4eca819eddf4f5a501137f13b743a259a52fff7a35d6e74278b797641ff259 folderview.plus-2026.08.24.04.txz diff --git a/docs/TROUBLESHOOTING.md b/docs/TROUBLESHOOTING.md index 1c785842..f407b0a4 100644 --- a/docs/TROUBLESHOOTING.md +++ b/docs/TROUBLESHOOTING.md @@ -113,6 +113,12 @@ User-uploaded icons are separate from the asset pack. Their persistent source is Standard, Adaptive, and Maximum must preserve configured preview content. If only expanded rows appear, copy runtime diagnostics and attach a sanitized support bundle before changing the folder membership. +### A Preview Container Menu Does Not Open + +Confirm the folder uses the Default preview context, then test the icon, name, and status in both the first visible preview row and the affected later row. Also test keyboard activation with Enter or Space. If the failure depends on Preview Rows `2`, `3`, `4`, or `Unlimited`, leave that setting unchanged while exporting the support bundle. + +The sanitized bundle retains a privacy-safe `uiTelemetry.dockerDiagnostics.previewContextBridge` summary across navigation to Settings. It records row-mode and numeric row-index buckets, eligible and bound bridge counts, post-layout handler-integrity results, dispatch attempts and outcomes, mouse or keyboard input, and icon/name/status/card trigger categories. It never records folder names, container names, IDs, selectors, URLs, or click coordinates. + ### Native Rows Briefly Appear Before Folders This is expected during initial Docker or VM bootstrap. FolderView Plus lets Unraid render its native rows first, then performs one uninterrupted folder conversion. The page should not remain half grouped or paint folder rows one at a time. @@ -185,6 +191,8 @@ The v2 bundle also includes exact build/package identity, loaded plugin script/s Docker support evidence includes recent session summaries, reload-source counts, the refresh-loop verdict, native busy-cycle recovery, and aggregate API identity mismatches. API mismatches record counts and first/last timestamps only. They never include container names or IDs, and they state the `native-structure-authoritative` policy and that an API mismatch did not request a host reload. +Docker preview-context evidence adds bounded bridge-binding, row-finalization, handler-integrity, and dispatch counters. Row modes distinguish `1`, `2`, `3`, `4`, and `Unlimited`; row positions are numeric buckets, and the most recent event contains only an outcome, bounded failure reason, trigger category, input method, row mode, row index, and timestamp. + To compare two systems without exposing their identities, export sanitized bundles from both and run: ```bash diff --git a/docs/releases/2026.08.24.01.md b/docs/releases/2026.08.24.01.md new file mode 100644 index 00000000..0f8e4248 --- /dev/null +++ b/docs/releases/2026.08.24.01.md @@ -0,0 +1,5 @@ +- Fix: Restore Docker preview context menus when Preview Rows is set to 2, 3, 4, or Unlimited, including first-click activation from the icon, name, and status area. +- Compatibility: Route compact Default-context previews through the original Unraid Docker control instead of copying host IDs or inline handlers into preview cards. +- Accessibility: Support Enter and Space on the preview context control without nesting the existing WebUI, console, or log actions inside another interactive element. +- Fix: Apply the correct started, paused, or stopped color classes to compact preview status text and icons. +- Test: Add accessibility-enabled Chromium and Firefox coverage for multi-row and Unlimited previews, keyboard activation, quick-action isolation, duplicate-ID prevention, and status styling. diff --git a/docs/releases/2026.08.24.02.md b/docs/releases/2026.08.24.02.md new file mode 100644 index 00000000..cfcff002 --- /dev/null +++ b/docs/releases/2026.08.24.02.md @@ -0,0 +1,3 @@ +- Fix: Keep Docker container preview context menus clickable after multi-row layout when Preview Rows is set to 2, 3, 4, or Unlimited. +- Reliability: Preserve preview interactions across repeated Docker and VM row layout reconciliation instead of removing their event handlers while rebuilding rows. +- Test: Exercise mouse, keyboard, status-area, and quick-action behavior after repeated two-row and Unlimited preview layout in Chromium and Firefox. diff --git a/docs/releases/2026.08.24.03.md b/docs/releases/2026.08.24.03.md new file mode 100644 index 00000000..a51b32c6 --- /dev/null +++ b/docs/releases/2026.08.24.03.md @@ -0,0 +1,3 @@ +- Diagnostics: Add privacy-safe Docker preview context-menu evidence to support bundles, including configured row mode, numeric preview-row position, binding integrity, trigger and input categories, dispatch outcomes, and bounded failure reasons. +- Privacy: Retain the diagnostic record briefly across page navigation without collecting folder or container names, identifiers, selectors, URLs, or pointer coordinates. +- Test: Verify multi-row and Unlimited preview context-menu diagnostics, persistence, failure detection, and privacy boundaries in Chromium and Firefox. diff --git a/docs/releases/2026.08.24.04.md b/docs/releases/2026.08.24.04.md new file mode 100644 index 00000000..e19806c6 --- /dev/null +++ b/docs/releases/2026.08.24.04.md @@ -0,0 +1,10 @@ +- Fix: Keep Docker preview context menus clickable in every visible row when Preview Rows is set to 2, 3, 4, or Unlimited, including after repeated layout reconciliation. +- Compatibility: Route Default-context preview activation through the original Unraid Docker control without copying host IDs or inline handlers, while preserving WebUI, logs, and console quick actions. +- Accessibility: Support first-click mouse activation from the preview icon, name, and status plus Enter and Space keyboard activation. +- Fix: Preserve the correct started, paused, and stopped styling on compact Docker preview status text and icons. +- Diagnostics: Add a short-lived, privacy-safe support-bundle summary of preview row mode and numeric row position, bridge binding and handler integrity, activation category, input method, dispatch outcomes, and bounded failure reasons. +- Privacy: Exclude folder and container identities, selectors, URLs, and pointer coordinates from preview context diagnostics. +- Maintenance: Add daily public-contract monitoring for Unraid stable and prerelease versions, PHP runtimes, Docker/VM/Dashboard and plugin-manager surfaces, Docker API/native-page signals, and Community Applications publication without live-Unraid credentials. +- Security: Add weekly OSV scanning of the generated SBOM, scheduled-workflow health coverage, and tool-scoped CodeQL alert enforcement so unrelated Scorecard findings do not fail CodeQL analysis. +- Compatibility: Refresh the reviewed Unraid/API baseline while retaining native-Docker safe mode and human approval for every upstream baseline change. +- Test: Cover multi-row and Unlimited preview interaction, repeated finalization, handler-loss detection, persistence, accessibility, status styling, and privacy boundaries in Chromium and Firefox. diff --git a/docs/sbom.cdx.json b/docs/sbom.cdx.json index 739b190e..723bcb79 100644 --- a/docs/sbom.cdx.json +++ b/docs/sbom.cdx.json @@ -1,13 +1,13 @@ { "bomFormat": "CycloneDX", "specVersion": "1.5", - "serialNumber": "urn:uuid:fc08839a-71b1-8d83-9a91-89ef86f15baa", + "serialNumber": "urn:uuid:8d6ef646-c87b-8601-8d30-57908769da9d", "version": 1, "metadata": { "component": { "type": "application", "name": "FolderView Plus", - "version": "2026.08.20.02", + "version": "2026.08.24.04", "properties": [ { "name": "folderview-plus:runtime-dependencies", diff --git a/docs/security/csp-readiness.json b/docs/security/csp-readiness.json index 6d1a7318..f93b851e 100644 --- a/docs/security/csp-readiness.json +++ b/docs/security/csp-readiness.json @@ -102,7 +102,7 @@ }, { "path": "src/folderview.plus/usr/local/emhttp/plugins/folderview.plus/scripts/docker.js", - "line": 3468, + "line": 3395, "sink": ".innerHTML =", "risk": "high", "dataClass": "persisted-or-runtime-data", diff --git a/folderview.plus.plg b/folderview.plus.plg index 1019ed99..a2ad93b3 100644 --- a/folderview.plus.plg +++ b/folderview.plus.plg @@ -6,9 +6,9 @@ - - - + + + @@ -18,12 +18,37 @@ -###2026.08.20.02 -- Fix: Allow protected FolderView Plus actions through a correctly configured TLS-terminating reverse proxy by validating the external host, protocol, and port as one coherent authority. -- Security: Keep strict POST, request-marker, install-token, one-time nonce, transaction, same-origin, and rate-limit controls while rejecting partial, repeated, malformed, spoofed-host, or conflicting forwarded headers. -- Diagnostics: Add privacy-safe request-security reason codes to diagnostics and support bundles without recording hostnames, addresses, ports, or raw forwarded-header values. -- Docs: Document supported SWAG forwarding, recovery from `Blocked by request guard`, and unsafe proxy workarounds to avoid. -- Test: Cover direct HTTP/HTTPS, standard and nonstandard proxy ports, IPv6, malformed or spoofed forwarded values, and Origin/Referer disagreements. +###2026.08.24.04 +- Fix: Keep Docker preview context menus clickable in every visible row when Preview Rows is set to 2, 3, 4, or Unlimited, including after repeated layout reconciliation. +- Compatibility: Route Default-context preview activation through the original Unraid Docker control without copying host IDs or inline handlers, while preserving WebUI, logs, and console quick actions. +- Accessibility: Support first-click mouse activation from the preview icon, name, and status plus Enter and Space keyboard activation. +- Fix: Preserve the correct started, paused, and stopped styling on compact Docker preview status text and icons. +- Diagnostics: Add a short-lived, privacy-safe support-bundle summary of preview row mode and numeric row position, bridge binding and handler integrity, activation category, input method, dispatch outcomes, and bounded failure reasons. +- Privacy: Exclude folder and container identities, selectors, URLs, and pointer coordinates from preview context diagnostics. +- Maintenance: Add daily public-contract monitoring for Unraid stable and prerelease versions, PHP runtimes, Docker/VM/Dashboard and plugin-manager surfaces, Docker API/native-page signals, and Community Applications publication without live-Unraid credentials. +- Security: Add weekly OSV scanning of the generated SBOM, scheduled-workflow health coverage, and tool-scoped CodeQL alert enforcement so unrelated Scorecard findings do not fail CodeQL analysis. +- Compatibility: Refresh the reviewed Unraid/API baseline while retaining native-Docker safe mode and human approval for every upstream baseline change. +- Test: Cover multi-row and Unlimited preview interaction, repeated finalization, handler-loss detection, persistence, accessibility, status styling, and privacy boundaries in Chromium and Firefox. + + +###2026.08.24.03 +- Diagnostics: Add privacy-safe Docker preview context-menu evidence to support bundles, including configured row mode, numeric preview-row position, binding integrity, trigger and input categories, dispatch outcomes, and bounded failure reasons. +- Privacy: Retain the diagnostic record briefly across page navigation without collecting folder or container names, identifiers, selectors, URLs, or pointer coordinates. +- Test: Verify multi-row and Unlimited preview context-menu diagnostics, persistence, failure detection, and privacy boundaries in Chromium and Firefox. + + +###2026.08.24.02 +- Fix: Keep Docker container preview context menus clickable after multi-row layout when Preview Rows is set to 2, 3, 4, or Unlimited. +- Reliability: Preserve preview interactions across repeated Docker and VM row layout reconciliation instead of removing their event handlers while rebuilding rows. +- Test: Exercise mouse, keyboard, status-area, and quick-action behavior after repeated two-row and Unlimited preview layout in Chromium and Firefox. + + +###2026.08.24.01 +- Fix: Restore Docker preview context menus when Preview Rows is set to 2, 3, 4, or Unlimited, including first-click activation from the icon, name, and status area. +- Compatibility: Route compact Default-context previews through the original Unraid Docker control instead of copying host IDs or inline handlers into preview cards. +- Accessibility: Support Enter and Space on the preview context control without nesting the existing WebUI, console, or log actions inside another interactive element. +- Fix: Apply the correct started, paused, or stopped color classes to compact preview status text and icons. +- Test: Add accessibility-enabled Chromium and Firefox coverage for multi-row and Unlimited previews, keyboard activation, quick-action isolation, duplicate-ID prevention, and status styling. ###2026.08.20.01 diff --git a/folderview.plus.xml b/folderview.plus.xml index 0b68f67e..c677cd37 100644 --- a/folderview.plus.xml +++ b/folderview.plus.xml @@ -10,7 +10,7 @@ FolderView Plus organizes Docker, VM, and Dashboard views into folders in Unraid, with starter setup tools, rules, bulk assignment, recovery, templates, and diagnostics. -2026-08-20 +2026-08-24 7.0.0 folder view docker vm dashboard organization groups sorting import export backup recovery rules templates diagnostics https://forums.unraid.net/topic/197631-plugin-folderview-plus/ diff --git a/scripts/codeql_alert_guard.mjs b/scripts/codeql_alert_guard.mjs index debd7bdd..436f31c8 100644 --- a/scripts/codeql_alert_guard.mjs +++ b/scripts/codeql_alert_guard.mjs @@ -3,8 +3,9 @@ import fs from 'node:fs'; import path from 'node:path'; import { pathToFileURL } from 'node:url'; -export const actionableAlertsForCommit = (alerts, commitSha) => (Array.isArray(alerts) ? alerts : []) +export const actionableAlertsForCommit = (alerts, commitSha, toolName = 'CodeQL') => (Array.isArray(alerts) ? alerts : []) .filter((alert) => String(alert?.state || '') === 'open') + .filter((alert) => !toolName || String(alert?.tool?.name || '') === toolName) .filter((alert) => !commitSha || String(alert?.most_recent_instance?.commit_sha || '') === commitSha); export const analysisAvailableForCommit = (analyses, commitSha, ref = '') => (Array.isArray(analyses) ? analyses : []) @@ -32,7 +33,7 @@ const parseArgs = (argv) => { const fetchOpenAlerts = async ({ repository, token, ref }) => { const alerts = []; for (let page = 1; page <= 10; page += 1) { - const query = new URLSearchParams({ state: 'open', per_page: '100', page: String(page) }); + const query = new URLSearchParams({ state: 'open', tool_name: 'CodeQL', per_page: '100', page: String(page) }); if (ref) query.set('ref', ref); const endpoint = `https://api.github.com/repos/${repository}/code-scanning/alerts?${query}`; const response = await fetch(endpoint, { diff --git a/scripts/csp_readiness_guard.mjs b/scripts/csp_readiness_guard.mjs index 901b0a4d..76365521 100644 --- a/scripts/csp_readiness_guard.mjs +++ b/scripts/csp_readiness_guard.mjs @@ -46,7 +46,7 @@ const patterns = { const lineNumberAt = (source, index) => source.slice(0, index).split('\n').length; const explicitHtmlSinkReviews = new Map([ ['src/folderview.plus/usr/local/emhttp/plugins/folderview.plus/scripts/docker.js:1294', 'Port endpoints and protocols are escaped by buildDockerPortEndpoint before the markup builder returns.'], - ['src/folderview.plus/usr/local/emhttp/plugins/folderview.plus/scripts/docker.js:3468', 'The loading overlay contains plugin-authored static markup only.'], + ['src/folderview.plus/usr/local/emhttp/plugins/folderview.plus/scripts/docker.js:3395', 'The loading overlay contains plugin-authored static markup only.'], ['src/folderview.plus/usr/local/emhttp/plugins/folderview.plus/scripts/docker.runtime.action-bar.js:274', 'Action, label, icon, title, and menu values are escaped by the local markup builders.'], ['src/folderview.plus/usr/local/emhttp/plugins/folderview.plus/scripts/docker.runtime.command-view.js:504', 'Names, identifiers, states, actions, and error text are escaped; counts are normalized numbers and image sources are sanitized.'], ['src/folderview.plus/usr/local/emhttp/plugins/folderview.plus/scripts/runtime.shared-controls.js:221', 'The stable-toggle controller accepts only plugin-owned markup builders and validates the expected input after mounting.'], diff --git a/scripts/test_runner_contracts.json b/scripts/test_runner_contracts.json index 18a3f170..6d0d9689 100644 --- a/scripts/test_runner_contracts.json +++ b/scripts/test_runner_contracts.json @@ -48,12 +48,13 @@ "supportFiles": [ "scripts/fixture_browser_tests.mjs", "scripts/lib/fixture-browser-server.mjs", - "scripts/lib/fixture-browser-runner.mjs" + "scripts/lib/fixture-browser-runner.mjs", + "tests/browser/helpers/docker-preview-context.mjs" ], - "testCount": 42, - "assertionCount": 534, - "pageEvaluateCount": 105, - "orderedTitleSha256": "ee3028477ae78db47c8f3487c5c43a5779b5b75e80a0f05e1f2c498a53116b44" + "testCount": 43, + "assertionCount": 557, + "pageEvaluateCount": 109, + "orderedTitleSha256": "491369c2cae7533952fe8634ccef5185f1a713d57d2b440273973accb8d212aa" } } } 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 f55ee5ab..619b5ba7 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 @@ -189,7 +189,7 @@ const finalizePreviewRows = typeof dockerRuntimeShared.finalizePreviewRows === ' } const addDividers = settings?.preview_vertical_bars === true; const barsColor = settings?.preview_vertical_bars_color || settings?.preview_border_color || ''; - $preview.empty(); + $preview.children().detach(); rowSlices.forEach((slice) => { const $row = $('
'); slice.forEach((wrapper, index) => { @@ -1395,8 +1395,8 @@ const buildDockerPreviewItem = ({ entry = {}, settings = {}, autostart = false } : ''; const compactStatusMarkup = shouldHidePreviewStatus ? '' - : ` - ${stateLabel} + : ` + ${stateLabel} `; const inlineStatusMarkup = shouldHidePreviewStatus ? '' @@ -1614,43 +1614,6 @@ const decorateDockerPreviewMemberTriggers = ($elements, folderId, containerName) .removeAttr('data-container-name') .removeAttr('title'); }; -const bindCompactPreviewDefaultContext = ($item, $sourceRow) => { - if (!$item || !$item.length || !$sourceRow || !$sourceRow.length) { - return; - } - const $sourceTrigger = $sourceRow.find('td.ct-name > span.outer > span.hand').first(); - const $fallbackTrigger = $sourceRow.find('td.ct-name > span.outer > span.inner > span.appname > a.exec').first(); - const $nativeTrigger = $sourceTrigger.length ? $sourceTrigger : $fallbackTrigger; - if (!$nativeTrigger.length) { - return; - } - const inlineClick = String($nativeTrigger.attr('onclick') || '').trim(); - const inlineContextMenu = String($nativeTrigger.attr('oncontextmenu') || '').trim(); - const title = String($nativeTrigger.attr('title') || '').trim(); - const targets = [ - $item, - $item.find('.hand').first(), - $item.find('.inner').first(), - $item.find('span.appname').first(), - $item.find('span.appname > a.exec').first() - ].filter(($target) => $target && $target.length); - targets.forEach(($target) => { - $target.addClass('hand'); - if (inlineClick) { - $target.attr('onclick', inlineClick); - } - if (inlineContextMenu) { - $target.attr('oncontextmenu', inlineContextMenu); - } - if (title) { - $target.attr('title', title); - } - }); - const $appLink = $item.find('span.appname > a.exec').first(); - if ($appLink.length && !$appLink.attr('href')) { - $appLink.attr('href', '#'); - } -}; const buildCompactPreviewDefaultContextItem = ($sourceRow, settings = {}, autostart = false) => { if (!$sourceRow || !$sourceRow.length) { return null; @@ -1662,6 +1625,7 @@ const buildCompactPreviewDefaultContextItem = ($sourceRow, settings = {}, autost return null; } const $item = $sourceOuter.clone(); + getDockerPreviewActionsApi().sanitizeDockerPreviewContextClone($item); const compactMode = previewMode >= 1 && previewMode <= 4 ? previewMode : 1; const previewStatusMode = normalizePreviewStatusMode(settings?.preview_status); $item.addClass(`fv-docker-preview-card fv-docker-preview-card-compact fv-docker-preview-mode-${compactMode}${autostartClass}`); @@ -1707,46 +1671,9 @@ const buildCompactPreviewDefaultContextItem = ($sourceRow, settings = {}, autost } $inner.append($meta); } + getDockerPreviewActionsApi().normalizeDockerPreviewStatusMarkup($item); return $item; }; -const bindCompactPreviewDefaultContextProxy = ($item) => { - if (!$item || !$item.length) { - return; - } - const $menuTrigger = $item.find('span.hand, span.appname > a.exec').filter(function() { - return String($(this).attr('onclick') || '').trim().length > 0 - || String($(this).attr('oncontextmenu') || '').trim().length > 0 - || $(this).hasClass('hand') - || $(this).hasClass('exec'); - }).first(); - if (!$menuTrigger.length) { - return; - } - const usingAppNameTrigger = $menuTrigger.is('span.appname > a.exec'); - const interactiveSelector = usingAppNameTrigger - ? 'span.appname, span.appname > a.exec, span.folder-element-custom-btn, span.folder-element-custom-btn > a, .fv-preview-actions-compact, .fv-preview-actions-compact *' - : '.hand, span.folder-element-custom-btn, span.folder-element-custom-btn > a, .fv-preview-actions-compact, .fv-preview-actions-compact *'; - $item - .off('.fvCompactDefaultContextProxy') - .on('click.fvCompactDefaultContextProxy', function(event) { - const $target = $(event.target); - if ($target.closest(interactiveSelector).length) { - return; - } - event.preventDefault(); - event.stopPropagation(); - $menuTrigger.trigger('click'); - }) - .on('contextmenu.fvCompactDefaultContextProxy', function(event) { - const $target = $(event.target); - if ($target.closest(interactiveSelector).length) { - return; - } - event.preventDefault(); - event.stopPropagation(); - $menuTrigger.trigger('contextmenu'); - }); -}; const decorateDockerFolderMemberRow = ($row, folderId, containerName) => { if (!$row || !$row.length) { return; @@ -5208,11 +5135,7 @@ const createFolder = (folder, id, positionInMainOrder, liveOrderArray, container const { $item, $tooltipTrigger } = builtPreview; $createdFolderPreview.append($item); if (folder.settings.context === 1) { - if (compactPreviewItem) { - bindCompactPreviewDefaultContextProxy($item); - } else { - bindCompactPreviewDefaultContext($item, $sourceRow); - } + getDockerPreviewActionsApi().bindDockerPreviewDefaultContextBridge($item, $sourceRow, folder.settings); return null; } if (folder.settings.context === 2 || folder.settings.context === 0) { @@ -5888,13 +5811,17 @@ const createFolder = (folder, id, positionInMainOrder, liveOrderArray, container if (FOLDER_VIEW_DEBUG_MODE) console.log(`[FV3_DEBUG] createFolder (id: ${id}): Wrapped preview spans with .folder-preview-wrapper.`); applyFolderPreviewLayout($(`tr.folder-id-${id} div.folder-preview`), folder.settings); layoutFolderPreviewRows($(`tr.folder-id-${id} div.folder-preview`), folder.settings); + getDockerPreviewActionsApi().auditDockerPreviewContextBridges($(`tr.folder-id-${id} div.folder-preview`), folder.settings); if (lazyPreviewActive) { const previewElement = $(`tr.folder-id-${id} div.folder-preview`).get(0); const rowElement = $(`tr.folder-id-${id}`).get(0); dockerDeferredPreviewController.defer(previewElement, { interactionTarget: rowElement, placeholder: `${combinedContainers.length} members ยท preview deferred`, - onHydrated: () => layoutFolderPreviewRows($(previewElement), folder.settings) + onHydrated: () => { + layoutFolderPreviewRows($(previewElement), folder.settings); + getDockerPreviewActionsApi().auditDockerPreviewContextBridges($(previewElement), folder.settings); + } }); } if (FOLDER_VIEW_DEBUG_MODE && folder.settings.preview_vertical_bars) console.log(`[FV3_DEBUG] createFolder (id: ${id}): Added preview_vertical_bars.`); diff --git a/src/folderview.plus/usr/local/emhttp/plugins/folderview.plus/scripts/docker.runtime.preview-actions.js b/src/folderview.plus/usr/local/emhttp/plugins/folderview.plus/scripts/docker.runtime.preview-actions.js index 9fa23959..90414d42 100644 --- a/src/folderview.plus/usr/local/emhttp/plugins/folderview.plus/scripts/docker.runtime.preview-actions.js +++ b/src/folderview.plus/usr/local/emhttp/plugins/folderview.plus/scripts/docker.runtime.preview-actions.js @@ -10,6 +10,174 @@ const fallbackWindow = typeof globalThis !== 'undefined' ? globalThis : (typeof window !== 'undefined' ? window : null); + const PREVIEW_CONTEXT_DIAGNOSTICS_STORAGE_KEY = 'fv.support.bundle.docker.previewContextBridge.v1'; + const PREVIEW_CONTEXT_DIAGNOSTICS_MAX_AGE_MS = 6 * 60 * 60 * 1000; + const PREVIEW_CONTEXT_ROW_MODES = Object.freeze(['1', '2', '3', '4', 'unlimited', 'unknown']); + const PREVIEW_CONTEXT_ROW_BUCKETS = Object.freeze(['1', '2', '3', '4', '5+', 'unknown']); + const PREVIEW_CONTEXT_COUNTER_KEYS = Object.freeze([ + 'bindAttempts', 'boundTargets', 'bindFailures', 'keyboardTargetsMissing', 'finalizationPasses', + 'eligibleTargetsAudited', 'boundTargetsAudited', 'missingBridgeTargets', 'handlerIntegrityFailures', + 'dispatchAttempts', 'dispatchSuccesses', 'dispatchFailures', 'quickActionBypasses', 'storageWriteFailures' + ]); + const PREVIEW_CONTEXT_FAILURE_REASONS = Object.freeze([ + 'item-missing', 'native-trigger-missing', 'keyboard-target-missing', 'bridge-missing', + 'handler-missing', 'native-event-unavailable', 'dispatch-failed' + ]); + const PREVIEW_CONTEXT_METRIC_KEYS = Object.freeze([ + 'bindings', 'audits', 'eligible', 'bound', 'missing', 'handlerFailures', + 'dispatchAttempts', 'dispatchSuccesses', 'dispatchFailures' + ]); + const hasOwn = (target, key) => Object.prototype.hasOwnProperty.call(target || {}, key); + const capPreviewContextCount = (value) => Math.max(0, Math.min(1000000, Math.round(Number(value) || 0))); + const normalizePreviewContextRowMode = (value) => { + const raw = String(value ?? '').trim().toLowerCase(); + if (raw === '0' || raw === 'unlimited') return 'unlimited'; + return PREVIEW_CONTEXT_ROW_MODES.includes(raw) ? raw : 'unknown'; + }; + const normalizePreviewContextRowIndex = (value) => { + const parsed = Math.max(0, Math.min(99, Math.round(Number(value) || 0))); + return parsed > 0 ? parsed : 0; + }; + const getPreviewContextRowBucket = (value) => { + const index = normalizePreviewContextRowIndex(value); + return index > 4 ? '5+' : (index > 0 ? String(index) : 'unknown'); + }; + const createPreviewContextMetricMap = (keys) => Object.fromEntries(keys.map((key) => [key, Object.fromEntries( + PREVIEW_CONTEXT_METRIC_KEYS.map((metric) => [metric, 0]) + )])); + const createPreviewContextDiagnosticsState = (nowIso) => ({ + schemaVersion: 1, + sessionStartedAt: nowIso, + updatedAt: nowIso, + counters: Object.fromEntries(PREVIEW_CONTEXT_COUNTER_KEYS.map((key) => [key, 0])), + rowModes: createPreviewContextMetricMap(PREVIEW_CONTEXT_ROW_MODES), + rowIndexes: createPreviewContextMetricMap(PREVIEW_CONTEXT_ROW_BUCKETS), + triggerSources: { icon: 0, name: 0, status: 0, card: 0, unknown: 0 }, + inputMethods: { mouse: 0, keyboard: 0, unknown: 0 }, + failureReasons: Object.fromEntries(PREVIEW_CONTEXT_FAILURE_REASONS.map((key) => [key, 0])), + lastEvent: null + }); + const createPreviewContextDiagnostics = (win) => { + const nowIso = () => new Date().toISOString(); + const state = createPreviewContextDiagnosticsState(nowIso()); + let storage = null; + try { storage = win?.localStorage || null; } catch (_error) { storage = null; } + try { + const stored = JSON.parse(String(storage?.getItem(PREVIEW_CONTEXT_DIAGNOSTICS_STORAGE_KEY) || 'null')); + const storedAt = Date.parse(String(stored?.updatedAt || '')); + if (stored?.schemaVersion === 1 && Number.isFinite(storedAt) && Date.now() - storedAt <= PREVIEW_CONTEXT_DIAGNOSTICS_MAX_AGE_MS) { + state.sessionStartedAt = String(stored.sessionStartedAt || state.sessionStartedAt); + PREVIEW_CONTEXT_COUNTER_KEYS.forEach((key) => { state.counters[key] = capPreviewContextCount(stored.counters?.[key]); }); + [ + [state.rowModes, stored.rowModes, PREVIEW_CONTEXT_ROW_MODES], + [state.rowIndexes, stored.rowIndexes, PREVIEW_CONTEXT_ROW_BUCKETS] + ].forEach(([target, source, keys]) => keys.forEach((key) => PREVIEW_CONTEXT_METRIC_KEYS.forEach((metric) => { + target[key][metric] = capPreviewContextCount(source?.[key]?.[metric]); + }))); + Object.keys(state.triggerSources).forEach((key) => { state.triggerSources[key] = capPreviewContextCount(stored.triggerSources?.[key]); }); + Object.keys(state.inputMethods).forEach((key) => { state.inputMethods[key] = capPreviewContextCount(stored.inputMethods?.[key]); }); + PREVIEW_CONTEXT_FAILURE_REASONS.forEach((key) => { state.failureReasons[key] = capPreviewContextCount(stored.failureReasons?.[key]); }); + const last = stored.lastEvent; + if (last && typeof last === 'object' && !Array.isArray(last)) { + state.lastEvent = { + at: String(last.at || ''), + type: ['bind', 'finalization', 'dispatch'].includes(last.type) ? last.type : 'dispatch', + outcome: ['success', 'failure', 'warning'].includes(last.outcome) ? last.outcome : 'failure', + rowMode: normalizePreviewContextRowMode(last.rowMode), + rowIndex: normalizePreviewContextRowIndex(last.rowIndex), + triggerSource: hasOwn(state.triggerSources, last.triggerSource) ? last.triggerSource : 'unknown', + inputMethod: hasOwn(state.inputMethods, last.inputMethod) ? last.inputMethod : 'unknown', + reason: PREVIEW_CONTEXT_FAILURE_REASONS.includes(last.reason) ? last.reason : '' + }; + } + } + } catch (_error) {} + let writePending = false; + const flush = () => { + writePending = false; + state.updatedAt = nowIso(); + try { + storage?.setItem(PREVIEW_CONTEXT_DIAGNOSTICS_STORAGE_KEY, JSON.stringify(state)); + return true; + } catch (_error) { + state.counters.storageWriteFailures = capPreviewContextCount(state.counters.storageWriteFailures + 1); + return false; + } + }; + const persist = (immediate = false) => { + if (immediate || typeof win?.setTimeout !== 'function') return flush(); + if (!writePending) { + writePending = true; + win.setTimeout(flush, 0); + } + return true; + }; + const bump = (target, key, amount = 1) => { + if (target && hasOwn(target, key)) target[key] = capPreviewContextCount(target[key] + (Number(amount) || 0)); + }; + const modeMetric = (details, name, amount = 1) => { + const mode = normalizePreviewContextRowMode(details.rowMode); + bump(state.rowModes[mode], name, amount); + }; + const rowMetric = (details, name, amount = 1) => { + const bucket = getPreviewContextRowBucket(details.rowIndex); + bump(state.rowIndexes[bucket], name, amount); + }; + const metric = (details, name, amount = 1) => { + modeMetric(details, name, amount); + rowMetric(details, name, amount); + }; + const setLastEvent = (type, details = {}) => { + state.lastEvent = { + at: nowIso(), type, outcome: details.outcome || 'success', + rowMode: normalizePreviewContextRowMode(details.rowMode), + rowIndex: normalizePreviewContextRowIndex(details.rowIndex), + triggerSource: hasOwn(state.triggerSources, details.triggerSource) ? details.triggerSource : 'unknown', + inputMethod: hasOwn(state.inputMethods, details.inputMethod) ? details.inputMethod : 'unknown', + reason: PREVIEW_CONTEXT_FAILURE_REASONS.includes(details.reason) ? details.reason : '' + }; + }; + return Object.freeze({ + recordBinding(details = {}) { + bump(state.counters, 'bindAttempts'); metric(details, 'bindings'); + if (details.success === true) bump(state.counters, 'boundTargets'); + else { bump(state.counters, 'bindFailures'); bump(state.failureReasons, details.reason); } + if (details.keyboardTargetMissing === true) { + bump(state.counters, 'keyboardTargetsMissing'); + bump(state.failureReasons, 'keyboard-target-missing'); + } + setLastEvent('bind', { ...details, outcome: details.success === true ? (details.keyboardTargetMissing ? 'warning' : 'success') : 'failure' }); + persist(); + }, + recordFinalization(details = {}) { + bump(state.counters, 'finalizationPasses'); modeMetric(details, 'audits'); + [['eligibleTargetsAudited', 'eligible'], ['boundTargetsAudited', 'bound'], ['missingBridgeTargets', 'missing'], ['handlerIntegrityFailures', 'handlerFailures']] + .forEach(([counter, name]) => { bump(state.counters, counter, details[counter]); modeMetric(details, name, details[counter]); }); + (details.rows || []).forEach((row) => { + const bucket = getPreviewContextRowBucket(row.rowIndex); + [['eligible', row.eligible], ['bound', row.bound], ['missing', row.missing], ['handlerFailures', row.handlerFailures]] + .forEach(([name, value]) => bump(state.rowIndexes[bucket], name, value)); + }); + const reason = details.handlerIntegrityFailures > 0 ? 'handler-missing' : (details.missingBridgeTargets > 0 ? 'bridge-missing' : ''); + if (reason) bump(state.failureReasons, reason, reason === 'handler-missing' ? details.handlerIntegrityFailures : details.missingBridgeTargets); + setLastEvent('finalization', { ...details, reason, outcome: reason ? 'failure' : 'success' }); + persist(); + }, + recordDispatch(details = {}) { + bump(state.counters, 'dispatchAttempts'); metric(details, 'dispatchAttempts'); + const success = details.success === true; + bump(state.counters, success ? 'dispatchSuccesses' : 'dispatchFailures'); + metric(details, success ? 'dispatchSuccesses' : 'dispatchFailures'); + bump(state.triggerSources, details.triggerSource); + bump(state.inputMethods, details.inputMethod); + if (!success) bump(state.failureReasons, details.reason); + setLastEvent('dispatch', { ...details, outcome: success ? 'success' : 'failure' }); + persist(true); + }, + recordQuickActionBypass() { bump(state.counters, 'quickActionBypasses'); persist(); }, + snapshot: () => JSON.parse(JSON.stringify(state)) + }); + }; const createApi = (deps = {}) => { const win = deps.window || fallbackWindow; @@ -42,11 +210,254 @@ const appendRequestBundleTrace = typeof deps.appendRequestBundleTrace === 'function' ? deps.appendRequestBundleTrace : (() => false); + const previewContextDiagnostics = createPreviewContextDiagnostics(win); const debug = deps.debug === true; const webuiLinkRel = String(deps.webuiLinkRel || 'noopener noreferrer').trim() || 'noopener noreferrer'; const dockerRuntimeStateClassList = 'started paused stopped fv-preview-status-started fv-preview-status-paused fv-preview-status-stopped green-text orange-text red-text'; const dockerRuntimeIconClassList = 'fa-play fa-pause fa-square fa-refresh fa-spin'; const dockerPreviewActionIconClassList = 'fa-globe fa-terminal fa-bars fa-refresh fa-spin fa-spinner fa-circle-o-notch'; + const dockerPreviewQuickActionSelector = [ + '.folder-element-custom-btn', + '.folder-element-custom-btn *', + '.fv-preview-actions-compact', + '.fv-preview-actions-compact *' + ].join(', '); + + const resolveDockerNativePreviewContextTrigger = ($sourceRow) => { + if (!jq || !$sourceRow || !$sourceRow.length || typeof $sourceRow.find !== 'function') { + return jq ? jq() : null; + } + const $iconTrigger = $sourceRow.find('td.ct-name > span.outer > span.hand').first(); + if ($iconTrigger.length) { + return $iconTrigger; + } + return $sourceRow.find('td.ct-name > span.outer > span.inner > span.appname > a.exec').first(); + }; + + const sanitizeDockerPreviewContextClone = ($item) => { + if (!$item || !$item.length) { + return $item; + } + $item + .removeAttr('id onclick oncontextmenu') + .find('[onclick], [oncontextmenu]') + .removeAttr('onclick oncontextmenu'); + $item.find('[id]').not('i[id^="load-"]').removeAttr('id'); + return $item; + }; + + const resolveDockerPreviewActivationPoint = ($item, event = null) => { + const sourceEvent = event?.originalEvent || event || {}; + const eventX = Number(sourceEvent.clientX); + const eventY = Number(sourceEvent.clientY); + if (Number.isFinite(eventX) && Number.isFinite(eventY) && sourceEvent.type !== 'keydown') { + return { clientX: eventX, clientY: eventY }; + } + const itemNode = $item?.get?.(0) || null; + const rect = typeof itemNode?.getBoundingClientRect === 'function' + ? itemNode.getBoundingClientRect() + : null; + return { + clientX: rect ? rect.left + Math.max(1, rect.width / 2) : 0, + clientY: rect ? rect.top + Math.max(1, rect.height / 2) : 0 + }; + }; + + const resolveDockerPreviewContextRowMode = ($item, settings = {}) => normalizePreviewContextRowMode( + $item?.attr?.('data-fv-preview-row-mode') + || $item?.closest?.('.folder-preview')?.attr?.('data-preview-rows') + || settings?.preview_rows + || settings?.previewRows + ); + + const resolveDockerPreviewContextRowIndex = ($item) => { + const $row = $item?.closest?.('.folder-preview-row'); + if (!$row?.length) return 1; + const $rows = $row.parent().children('.folder-preview-row'); + return Math.max(1, $rows.index($row) + 1); + }; + + const resolveDockerPreviewContextTriggerSource = (event = null) => { + if (!jq || !event?.target) return 'unknown'; + const $target = jq(event.target); + if ($target.closest('span.hand').length) return 'icon'; + if ($target.closest('span.appname').length) return 'name'; + if ($target.closest('span.state, .fv-preview-status-compact').length) return 'status'; + return 'card'; + }; + + const hasNamespacedPreviewContextHandler = (node, eventType) => { + if (!node || typeof jq?._data !== 'function') return null; + const handlers = jq._data(node, 'events')?.[eventType]; + return Array.isArray(handlers) + ? handlers.some((handler) => String(handler?.namespace || '').split('.').includes('fvDockerNativePreviewContext')) + : false; + }; + + const dispatchDockerNativePreviewContext = ($nativeTrigger, $item, event = null) => { + const trigger = $nativeTrigger?.get?.(0) || null; + if (!trigger || typeof trigger.dispatchEvent !== 'function') { + return { ok: false, reason: 'native-trigger-missing' }; + } + const { clientX, clientY } = resolveDockerPreviewActivationPoint($item, event); + const MouseEventConstructor = win?.MouseEvent || fallbackWindow?.MouseEvent; + if (typeof MouseEventConstructor !== 'function') { + return { ok: false, reason: 'native-event-unavailable' }; + } + try { + trigger.dispatchEvent(new MouseEventConstructor('click', { + bubbles: true, + cancelable: true, + view: win || undefined, + button: 0, + buttons: 0, + clientX, + clientY + })); + return { ok: true, reason: '' }; + } catch (_error) { + return { ok: false, reason: 'dispatch-failed' }; + } + }; + + const recordDockerPreviewContextDispatch = ($nativeTrigger, $item, event = null) => { + const result = dispatchDockerNativePreviewContext($nativeTrigger, $item, event); + previewContextDiagnostics.recordDispatch({ + success: result.ok, + reason: result.reason, + rowMode: resolveDockerPreviewContextRowMode($item), + rowIndex: resolveDockerPreviewContextRowIndex($item), + triggerSource: resolveDockerPreviewContextTriggerSource(event), + inputMethod: event?.type === 'keydown' ? 'keyboard' : 'mouse' + }); + return result.ok; + }; + + const bindDockerPreviewDefaultContextBridge = ($item, $sourceRow, settings = {}) => { + const rowMode = resolveDockerPreviewContextRowMode($item, settings); + if (!jq || !$item || !$item.length) { + previewContextDiagnostics.recordBinding({ success: false, reason: 'item-missing', rowMode }); + return false; + } + $item.attr('data-fv-preview-context-eligible', 'true').attr('data-fv-preview-row-mode', rowMode); + const $nativeTrigger = resolveDockerNativePreviewContextTrigger($sourceRow); + if (!$nativeTrigger || !$nativeTrigger.length) { + $item.attr('data-fv-preview-context-bound', 'false'); + previewContextDiagnostics.recordBinding({ success: false, reason: 'native-trigger-missing', rowMode }); + return false; + } + const nativeTitle = String($nativeTrigger.attr('title') || '').trim(); + sanitizeDockerPreviewContextClone($item); + const $keyboardTarget = $item.find('span.hand, span.appname').first(); + $item + .removeClass('fv-preview-trigger fv-preview-tooltip-proxy') + .removeAttr('role tabindex data-fv-preview-context') + .find('.fv-preview-trigger, .fv-preview-tooltip-proxy') + .removeClass('fv-preview-trigger fv-preview-tooltip-proxy'); + if ($keyboardTarget.length) { + $keyboardTarget + .attr('role', 'button') + .attr('tabindex', '0') + .attr('data-fv-preview-context', 'native'); + if (nativeTitle) { + $keyboardTarget.attr('title', nativeTitle); + } + } + $item + .attr('data-fv-preview-context-bound', 'true') + .off('.fvDockerNativePreviewContext') + .on('click.fvDockerNativePreviewContext', function(event) { + if (jq(event.target).closest(dockerPreviewQuickActionSelector).length) { + previewContextDiagnostics.recordQuickActionBypass(); + return; + } + event.preventDefault(); + event.stopPropagation(); + recordDockerPreviewContextDispatch($nativeTrigger, $item, event); + }); + $keyboardTarget + .off('.fvDockerNativePreviewContext') + .on('keydown.fvDockerNativePreviewContext', function(event) { + if (!['Enter', ' '].includes(event.key)) { + return; + } + event.preventDefault(); + event.stopPropagation(); + recordDockerPreviewContextDispatch($nativeTrigger, $item, event); + }); + previewContextDiagnostics.recordBinding({ + success: true, + rowMode, + keyboardTargetMissing: !$keyboardTarget.length + }); + return true; + }; + + const auditDockerPreviewContextBridges = ($preview, settings = {}) => { + if (!jq || !$preview || !$preview.length) return null; + const rowMode = resolveDockerPreviewContextRowMode($preview, settings); + const $eligibleTargets = $preview.find('[data-fv-preview-context-eligible="true"]'); + if (!$eligibleTargets.length) return null; + const rows = new Map(); + let eligibleTargetsAudited = 0; + let boundTargetsAudited = 0; + let missingBridgeTargets = 0; + let handlerIntegrityFailures = 0; + $eligibleTargets.each((_, node) => { + const $item = jq(node); + const rowIndex = resolveDockerPreviewContextRowIndex($item); + const row = rows.get(rowIndex) || { rowIndex, eligible: 0, bound: 0, missing: 0, handlerFailures: 0 }; + const bound = $item.attr('data-fv-preview-context-bound') === 'true'; + const $keyboardTarget = $item.find('[data-fv-preview-context="native"]').first(); + const clickHandlerPresent = hasNamespacedPreviewContextHandler(node, 'click'); + const keyboardHandlerPresent = $keyboardTarget.length + ? hasNamespacedPreviewContextHandler($keyboardTarget.get(0), 'keydown') + : null; + const handlerMissing = clickHandlerPresent === false || keyboardHandlerPresent === false; + $item.attr('data-fv-preview-row-index', String(rowIndex)); + eligibleTargetsAudited += 1; + row.eligible += 1; + if (bound) { boundTargetsAudited += 1; row.bound += 1; } + else { missingBridgeTargets += 1; row.missing += 1; } + if (handlerMissing) { handlerIntegrityFailures += 1; row.handlerFailures += 1; } + rows.set(rowIndex, row); + }); + const details = { + rowMode, + rowIndex: 0, + eligibleTargetsAudited, + boundTargetsAudited, + missingBridgeTargets, + handlerIntegrityFailures, + rows: [...rows.values()].slice(0, 10) + }; + previewContextDiagnostics.recordFinalization(details); + return details; + }; + + const normalizeDockerPreviewStatusMarkup = ($target) => { + if (!$target || !$target.length) { + return ''; + } + const $statusIcon = $target.find('i.started, i.paused, i.stopped').first(); + if (!$statusIcon.length) { + return ''; + } + const stateClass = $statusIcon.hasClass('paused') + ? 'fv-preview-status-paused' + : ($statusIcon.hasClass('stopped') ? 'fv-preview-status-stopped' : 'fv-preview-status-started'); + const $status = $target.find('.fv-preview-status-compact').first(); + if (!$status.length) { + return ''; + } + $status + .removeClass('fv-preview-status-started fv-preview-status-paused fv-preview-status-stopped') + .addClass(stateClass) + .find('i.fa, span.state') + .removeClass('fv-preview-status-started fv-preview-status-paused fv-preview-status-stopped') + .addClass(stateClass); + return stateClass; + }; const cloneDockerSingleRowPreviewSource = ($sourceRow, selector, options = {}) => { if (!jq) { @@ -772,10 +1183,16 @@ }); applyFolderPreviewLayout($preview, settings); layoutFolderPreviewRows($preview, settings); + auditDockerPreviewContextBridges($preview, settings); $preview.find('span.inner > span.appname').css('width', settings?.preview_text_width || ''); }; return Object.freeze({ + sanitizeDockerPreviewContextClone, + bindDockerPreviewDefaultContextBridge, + auditDockerPreviewContextBridges, + getPreviewContextDiagnosticsSnapshot: previewContextDiagnostics.snapshot, + normalizeDockerPreviewStatusMarkup, cloneDockerSingleRowPreviewSource, renderDockerSingleRowPreview, runDockerPreviewRenderer, @@ -790,6 +1207,7 @@ }; return Object.freeze({ - createApi + createApi, + PREVIEW_CONTEXT_DIAGNOSTICS_STORAGE_KEY }); })); diff --git a/src/folderview.plus/usr/local/emhttp/plugins/folderview.plus/scripts/docker.runtime.shared.js b/src/folderview.plus/usr/local/emhttp/plugins/folderview.plus/scripts/docker.runtime.shared.js index 4d4256b6..e168583d 100644 --- a/src/folderview.plus/usr/local/emhttp/plugins/folderview.plus/scripts/docker.runtime.shared.js +++ b/src/folderview.plus/usr/local/emhttp/plugins/folderview.plus/scripts/docker.runtime.shared.js @@ -342,7 +342,7 @@ } const addDividers = settings?.preview_vertical_bars === true; const barsColor = settings?.preview_vertical_bars_color || settings?.preview_border_color || ''; - $preview.empty(); + $preview.children().detach(); const addRowSeparators = settings?.preview_row_separator === true || settings?.previewRowSeparator === true; const rowSeparatorColor = settings?.preview_row_separator_color || settings?.previewRowSeparatorColor || barsColor; rowSlices.forEach((slice, rowIndex) => { diff --git a/src/folderview.plus/usr/local/emhttp/plugins/folderview.plus/scripts/folderviewplus.activity-diagnostics.js b/src/folderview.plus/usr/local/emhttp/plugins/folderview.plus/scripts/folderviewplus.activity-diagnostics.js index 1de4a508..89d3ffd1 100644 --- a/src/folderview.plus/usr/local/emhttp/plugins/folderview.plus/scripts/folderviewplus.activity-diagnostics.js +++ b/src/folderview.plus/usr/local/emhttp/plugins/folderview.plus/scripts/folderviewplus.activity-diagnostics.js @@ -672,6 +672,7 @@ const getSupportBundleTelemetryApi = () => { dockerRequestBundleTrace: 'fv.support.bundle.docker.requestBundleTrace.v1', dockerTraceHealth: 'fv.support.bundle.docker.traceHealth.v1', dockerRefreshDiagnostics: 'fv.support.bundle.docker.refreshDiagnostics.v1', + dockerPreviewContext: 'fv.support.bundle.docker.previewContextBridge.v1', dashboardLayoutDocker: 'fv.support.bundle.dashboard.layout.docker.v1', dashboardLayoutVm: 'fv.support.bundle.dashboard.layout.vm.v1', dashboardVisualDocker: 'fv.support.bundle.dashboard.visual.docker.v1', @@ -694,7 +695,6 @@ const getSupportBundleTelemetryApi = () => { } return supportBundleTelemetryApi; }; - const collectSupportBundleUiTelemetry = (bundle) => { const telemetryApi = getSupportBundleTelemetryApi(); if (telemetryApi && typeof telemetryApi.collectSupportBundleUiTelemetry === 'function') { diff --git a/src/folderview.plus/usr/local/emhttp/plugins/folderview.plus/scripts/folderviewplus.support-bundle-browser.js b/src/folderview.plus/usr/local/emhttp/plugins/folderview.plus/scripts/folderviewplus.support-bundle-browser.js index 692b30c9..ef05760f 100644 --- a/src/folderview.plus/usr/local/emhttp/plugins/folderview.plus/scripts/folderviewplus.support-bundle-browser.js +++ b/src/folderview.plus/usr/local/emhttp/plugins/folderview.plus/scripts/folderviewplus.support-bundle-browser.js @@ -350,7 +350,6 @@ ...record }); }; - const collectDashboardLayoutDiagnostics = (uiRedactor) => { const collectType = (type, storageKey) => { const record = readClientDiagnosticsStorageRecord(storageKey || ''); diff --git a/src/folderview.plus/usr/local/emhttp/plugins/folderview.plus/scripts/folderviewplus.support-bundle-telemetry.js b/src/folderview.plus/usr/local/emhttp/plugins/folderview.plus/scripts/folderviewplus.support-bundle-telemetry.js index e1f3c8b9..ba78f2e3 100644 --- a/src/folderview.plus/usr/local/emhttp/plugins/folderview.plus/scripts/folderviewplus.support-bundle-telemetry.js +++ b/src/folderview.plus/usr/local/emhttp/plugins/folderview.plus/scripts/folderviewplus.support-bundle-telemetry.js @@ -395,6 +395,15 @@ const collectDockerBulkUpdateTrace = browserCollectors?.collectDockerBulkUpdateTrace || (() => ({ available: false })); const collectDockerRequestBundleTrace = browserCollectors?.collectDockerRequestBundleTrace || (() => ({ available: false })); const collectDockerTraceHealth = browserCollectors?.collectDockerTraceHealth || (() => ({ available: false })); + const collectDockerPreviewContextDiagnostics = (uiRedactor) => { + const record = readClientDiagnosticsStorageRecord(storageKeys.dockerPreviewContext || ''); + const available = Boolean(record && typeof record === 'object' && !Array.isArray(record)); + return uiRedactor.sanitizeValue( + 'uiTelemetry.dockerDiagnostics.previewContextBridge', + 'previewContextBridge', + available ? { available, ...record } : { available } + ); + }; const collectDashboardLayoutDiagnostics = browserCollectors?.collectDashboardLayoutDiagnostics || (() => ({ docker: { available: false }, vm: { available: false } @@ -552,7 +561,8 @@ bulkUpdateTrace: collectDockerBulkUpdateTrace(uiRedactor), requestBundleTrace: collectDockerRequestBundleTrace(uiRedactor), traceHealth: collectDockerTraceHealth(uiRedactor), - refreshDiagnostics: browserCollectors?.collectDockerRefreshDiagnostics?.(uiRedactor) || { available: false } + refreshDiagnostics: browserCollectors?.collectDockerRefreshDiagnostics?.(uiRedactor) || { available: false }, + previewContextBridge: collectDockerPreviewContextDiagnostics(uiRedactor) }; existingUiTelemetry.dashboardLayout = collectDashboardLayoutDiagnostics(uiRedactor); existingUiTelemetry.dashboardVisual = collectDashboardVisualDiagnostics(uiRedactor, { 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 59dcb024..5b274cb9 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 @@ -136,7 +136,7 @@ const finalizePreviewRows = typeof runtimeShared.finalizePreviewRows === 'functi } const addDividers = settings?.preview_vertical_bars === true; const barsColor = settings?.preview_vertical_bars_color || settings?.preview_border_color || ''; - $preview.empty(); + $preview.children().detach(); rowSlices.forEach((slice) => { const $row = $('
'); slice.forEach((wrapper, index) => { diff --git a/tests/browser/cases/docker.mjs b/tests/browser/cases/docker.mjs index 5facb9f8..921808bb 100644 --- a/tests/browser/cases/docker.mjs +++ b/tests/browser/cases/docker.mjs @@ -1,4 +1,5 @@ import assert from 'node:assert/strict'; +import { exerciseDockerPreviewContextDiagnostics } from '../helpers/docker-preview-context.mjs'; export const registerDockerFixtureCases = ({ test, baseUrl }) => { test('Docker action bar is idempotent and reports fixture counts', async ({ page }) => { @@ -63,6 +64,10 @@ test('Docker single-row preview cloning falls back without stopping later member assert.equal(result.fallbackCount, 2); }); +test('Docker multi-row previews bridge native context without cloned handlers or duplicate ids', async ({ page }) => { + await exerciseDockerPreviewContextDiagnostics({ page, baseUrl }); +}); + test('Docker folder context menu opens from the first folder-icon click', async ({ page }) => { await page.goto(`${baseUrl}/docker-layout-stability`, { waitUntil: 'load' }); const result = await page.evaluate(() => window.fixtureFolderContextFirstClick.run()); diff --git a/tests/browser/fixtures/docker-layout-stability.html b/tests/browser/fixtures/docker-layout-stability.html index c139ebe9..e76819ef 100644 --- a/tests/browser/fixtures/docker-layout-stability.html +++ b/tests/browser/fixtures/docker-layout-stability.html @@ -28,6 +28,34 @@ width: var(--fvplus-docker-app-column-width); height: 20px; } + + .fixture-context-preview { + display: flex; + width: 760px; + min-height: 42px; + margin-top: 10px; + } + + .fixture-context-preview .folder-preview-row, + .fixture-context-preview .folder-preview-wrapper, + .fixture-context-preview .fv-docker-preview-card { + display: flex; + align-items: center; + } + + .fixture-context-preview .fv-docker-preview-card { + gap: 6px; + min-width: 240px; + min-height: 34px; + padding: 4px 8px; + } + + .fixture-context-preview .hand { + display: inline-flex; + width: 28px; + height: 28px; + background: #777; + } @@ -43,6 +71,29 @@
+ + + + + + + + + + +
+
+ @@ -133,6 +184,7 @@ }; } }; + window.localStorage.removeItem(window.FolderViewPlusDockerPreviewActions.PREVIEW_CONTEXT_DIAGNOSTICS_STORAGE_KEY); const actionsApi = window.FolderViewPlusDockerPreviewActions.createApi({ window, document, @@ -148,6 +200,107 @@ openWebuiInNewTab: () => {}, openTerminal: () => {} }); + window.fixtureNativePreviewContext = { + attachCount: 0, + openCount: 0, + editCount: 0, + quickActionCount: 0, + lastKey: '', + lastPoint: null, + attach(event, key) { + this.attachCount += 1; + const triggerId = String(event?.currentTarget?.id || ''); + const namespace = `.fvFixtureNativeContext${String(key || '').replace(/[^a-z0-9]/gi, '')}`; + window.jQuery(document) + .off(`click${namespace}`, `#${triggerId}`) + .on(`click${namespace}`, `#${triggerId}`, (openEvent) => { + openEvent.preventDefault(); + openEvent.stopPropagation(); + this.openCount += 1; + this.lastKey = key; + this.lastPoint = { + clientX: Number(openEvent.clientX || 0), + clientY: Number(openEvent.clientY || 0) + }; + document.querySelector('#fixture-native-context-menu').hidden = false; + }); + }, + edit(event) { + event?.preventDefault?.(); + this.editCount += 1; + }, + reset() { + this.attachCount = 0; + this.openCount = 0; + this.editCount = 0; + this.quickActionCount = 0; + this.lastKey = ''; + this.lastPoint = null; + document.querySelector('#fixture-native-context-menu').hidden = true; + } + }; + const buildNativeContextPreview = (key) => { + const $sourceRow = window.jQuery(`#fixture-context-source-${key}`); + const $preview = window.jQuery(`#fixture-context-preview-${key}`); + const runtimeShared = window.FolderViewDockerRuntimeShared; + const settings = { + preview_rows: key === 'unlimited' ? 0 : 2, + preview_vertical_bars: false + }; + const items = [0, 1].map(() => { + const $item = $sourceRow.find('td.ct-name > span.outer').first().clone(); + actionsApi.sanitizeDockerPreviewContextClone($item); + $item.find('br').remove(); + $item.addClass('fv-docker-preview-card fv-docker-preview-card-compact fv-docker-preview-mode-1'); + const $inner = $item.children('span.inner').first(); + const $appName = $inner.children('span.appname').first(); + const $status = window.jQuery(''); + $appName.nextAll().each((_, node) => $status.append(node)); + const $quickActions = window.jQuery(''); + $quickActions.on('click', (event) => { + event.preventDefault(); + event.stopPropagation(); + window.fixtureNativePreviewContext.quickActionCount += 1; + }); + $inner.append(window.jQuery('').append($status, $quickActions)); + actionsApi.normalizeDockerPreviewStatusMarkup($item); + $preview.append(window.jQuery('
').append($item)); + actionsApi.bindDockerPreviewDefaultContextBridge($item, $sourceRow, settings); + return $item; + }); + for (let pass = 0; pass < 2; pass += 1) { + const wrappers = runtimeShared.flattenPreviewWrappers($preview); + runtimeShared.finalizePreviewRows($preview, wrappers.map((wrapper) => [wrapper]), settings); + actionsApi.auditDockerPreviewContextBridges($preview, settings); + } + return items; + }; + buildNativeContextPreview('rows-2'); + buildNativeContextPreview('unlimited'); + window.fixtureNativePreviewContext.breakRows2HandlersAndAudit = () => { + const $preview = window.jQuery('#fixture-context-preview-rows-2'); + const rows = $preview.children('.folder-preview-row').get(); + $preview.empty(); + rows.forEach((row) => $preview.append(row)); + return actionsApi.auditDockerPreviewContextBridges($preview, { preview_rows: 2 }); + }; + window.fixtureNativePreviewContext.snapshot = () => ({ + attachCount: window.fixtureNativePreviewContext.attachCount, + openCount: window.fixtureNativePreviewContext.openCount, + editCount: window.fixtureNativePreviewContext.editCount, + quickActionCount: window.fixtureNativePreviewContext.quickActionCount, + lastKey: window.fixtureNativePreviewContext.lastKey, + lastPoint: window.fixtureNativePreviewContext.lastPoint, + menuVisible: !document.querySelector('#fixture-native-context-menu').hidden, + sourceRows2IdCount: document.querySelectorAll('#fixture-native-rows-2').length, + sourceUnlimitedIdCount: document.querySelectorAll('#fixture-native-unlimited').length, + previewNativeIdCount: document.querySelectorAll('.fixture-context-preview [id^="fixture-native-"]').length, + previewInlineHandlerCount: document.querySelectorAll('.fixture-context-preview [onclick], .fixture-context-preview [oncontextmenu]').length, + startedClassCount: document.querySelectorAll('.fixture-context-preview .state.fv-preview-status-started').length, + bridgeCount: document.querySelectorAll('.fixture-context-preview [data-fv-preview-context="native"][role="button"][tabindex="0"]').length, + previewRowCount: document.querySelectorAll('.fixture-context-preview .folder-preview-row').length, + diagnostics: actionsApi.getPreviewContextDiagnosticsSnapshot() + }); const layoutEngine = window.FolderViewPlusRuntimeColumnLayout.createColumnLayoutEngine({ minWidth: 118, maxWidth: 1280, diff --git a/tests/browser/helpers/docker-preview-context.mjs b/tests/browser/helpers/docker-preview-context.mjs new file mode 100644 index 00000000..d13d3fc0 --- /dev/null +++ b/tests/browser/helpers/docker-preview-context.mjs @@ -0,0 +1,85 @@ +import assert from 'node:assert/strict'; + +export const exerciseDockerPreviewContextDiagnostics = async ({ page, baseUrl }) => { + await page.goto(`${baseUrl}/docker-layout-stability`, { waitUntil: 'load' }); + const snapshot = () => page.evaluate(() => window.fixtureNativePreviewContext.snapshot()); + const exercise = async (selector, key = '') => { + await page.evaluate(() => window.fixtureNativePreviewContext.reset()); + const target = page.locator(selector); + key ? await target.press(key) : await target.click(); + return snapshot(); + }; + let result = await exercise('#fixture-context-preview-rows-2 .folder-preview-row:first-of-type .hand'); + assert.deepEqual( + [result.attachCount, result.openCount, result.lastKey, result.menuVisible], + [1, 1, 'rows-2', true] + ); + assert.ok(result.lastPoint.clientX > 0); + assert.ok(result.lastPoint.clientY > 0); + assert.deepEqual( + [result.diagnostics.lastEvent.type, result.diagnostics.lastEvent.outcome, result.diagnostics.lastEvent.rowMode, + result.diagnostics.lastEvent.rowIndex, result.diagnostics.lastEvent.triggerSource, result.diagnostics.lastEvent.inputMethod], + ['dispatch', 'success', '2', 1, 'icon', 'mouse'] + ); + result = await exercise('#fixture-context-preview-unlimited .folder-preview-row:first-of-type .appname'); + assert.deepEqual([result.openCount, result.editCount, result.lastKey], [1, 0, 'unlimited']); + assert.deepEqual( + [result.diagnostics.lastEvent.rowMode, result.diagnostics.lastEvent.rowIndex, result.diagnostics.lastEvent.triggerSource], + ['unlimited', 1, 'name'] + ); + result = await exercise('#fixture-context-preview-rows-2 .folder-preview-row:nth-of-type(2) .state'); + assert.deepEqual([result.openCount, result.lastKey], [1, 'rows-2']); + assert.deepEqual( + [result.diagnostics.lastEvent.rowMode, result.diagnostics.lastEvent.rowIndex, result.diagnostics.lastEvent.triggerSource], + ['2', 2, 'status'] + ); + result = await exercise('#fixture-context-preview-rows-2 .folder-preview-row:first-of-type [data-fv-preview-context="native"]', 'Enter'); + assert.deepEqual([result.openCount, result.lastKey], [1, 'rows-2']); + assert.equal(result.diagnostics.lastEvent.inputMethod, 'keyboard'); + result = await exercise('#fixture-context-preview-unlimited .folder-preview-row:nth-of-type(2) [data-fv-preview-context="native"]', 'Space'); + assert.deepEqual([result.openCount, result.lastKey], [1, 'unlimited']); + result = await exercise('#fixture-context-preview-rows-2 .folder-preview-row:first-of-type .fixture-context-quick-action'); + assert.deepEqual( + [result.quickActionCount, result.openCount, result.sourceRows2IdCount, result.sourceUnlimitedIdCount, + result.previewNativeIdCount, result.previewInlineHandlerCount, result.startedClassCount, result.bridgeCount, + result.previewRowCount], + [1, 0, 1, 1, 0, 0, 4, 4, 4] + ); + assert.deepEqual( + [result.diagnostics.counters.bindAttempts, result.diagnostics.counters.boundTargets, + result.diagnostics.counters.bindFailures, result.diagnostics.counters.finalizationPasses, + result.diagnostics.counters.eligibleTargetsAudited, result.diagnostics.counters.boundTargetsAudited, + result.diagnostics.counters.missingBridgeTargets, result.diagnostics.counters.handlerIntegrityFailures, + result.diagnostics.counters.dispatchAttempts, result.diagnostics.counters.dispatchSuccesses, + result.diagnostics.counters.dispatchFailures], + [4, 4, 0, 4, 8, 8, 0, 0, 5, 5, 0] + ); + assert.equal(result.diagnostics.rowModes['2'].dispatchSuccesses, 3); + assert.equal(result.diagnostics.rowModes.unlimited.dispatchSuccesses, 2); + assert.equal(result.diagnostics.rowIndexes['2'].dispatchSuccesses, 2); + assert.equal(result.diagnostics.triggerSources.status, 1); + assert.equal(result.diagnostics.inputMethods.keyboard, 2); + assert.doesNotMatch(JSON.stringify(result.diagnostics), /rows-2|fixture-native|fixture-context|clientX|clientY/); + const persisted = await page.evaluate(() => window.FolderViewPlusDockerPreviewActions.createApi({ + window, + $: window.jQuery + }).getPreviewContextDiagnosticsSnapshot()); + assert.deepEqual( + [persisted.counters.dispatchSuccesses, persisted.rowModes['2'].dispatchSuccesses, + persisted.rowModes.unlimited.dispatchSuccesses, persisted.rowIndexes['2'].dispatchSuccesses], + [5, 3, 2, 2] + ); + const failedAudit = await page.evaluate(() => window.fixtureNativePreviewContext.breakRows2HandlersAndAudit()); + result = await snapshot(); + assert.deepEqual( + [failedAudit.eligibleTargetsAudited, failedAudit.boundTargetsAudited, failedAudit.missingBridgeTargets, + failedAudit.handlerIntegrityFailures], + [2, 2, 0, 2] + ); + assert.deepEqual( + [result.diagnostics.lastEvent.type, result.diagnostics.lastEvent.outcome, result.diagnostics.lastEvent.rowMode, + result.diagnostics.lastEvent.reason], + ['finalization', 'failure', '2', 'handler-missing'] + ); + assert.equal(result.diagnostics.failureReasons['handler-missing'], 2); +}; diff --git a/tests/codeql-alert-guard.test.mjs b/tests/codeql-alert-guard.test.mjs index 1e8728d8..9a3db67f 100644 --- a/tests/codeql-alert-guard.test.mjs +++ b/tests/codeql-alert-guard.test.mjs @@ -3,9 +3,10 @@ import assert from 'node:assert/strict'; import { actionableAlertsForCommit, analysisAvailableForCommit } from '../scripts/codeql_alert_guard.mjs'; -const alert = ({ state = 'open', sha = 'current', number = 1 } = {}) => ({ +const alert = ({ state = 'open', sha = 'current', number = 1, tool = 'CodeQL' } = {}) => ({ number, state, + tool: { name: tool }, rule: { id: 'js/example' }, most_recent_instance: { commit_sha: sha, @@ -17,7 +18,8 @@ test('CodeQL guard reports only open alerts associated with the analyzed commit' const alerts = [ alert({ number: 1 }), alert({ number: 2, state: 'dismissed' }), - alert({ number: 3, sha: 'older' }) + alert({ number: 3, sha: 'older' }), + alert({ number: 4, tool: 'Scorecard' }) ]; assert.deepEqual(actionableAlertsForCommit(alerts, 'current').map((entry) => entry.number), [1]); assert.deepEqual(actionableAlertsForCommit(alerts, 'clean'), []); diff --git a/tests/docker-preview-overflow-feature.test.mjs b/tests/docker-preview-overflow-feature.test.mjs index 0415c0d7..9f85df68 100644 --- a/tests/docker-preview-overflow-feature.test.mjs +++ b/tests/docker-preview-overflow-feature.test.mjs @@ -75,6 +75,8 @@ test('docker runtime applies preview row layout limits and keeps compact preview assert.match(sharedRuntimeJs, /const flattenPreviewWrappers = \(\$preview\) =>/); assert.match(sharedRuntimeJs, /const restoreLinearPreviewLayout = \(\$preview,\s*settings = \{\}\) =>/); assert.match(sharedRuntimeJs, /const finalizePreviewRows = \(\$preview,\s*rowSlices = \[\],\s*settings = \{\}\) =>/); + assert.match(sharedRuntimeJs, /\$preview\.children\(\)\.detach\(\);/); + assert.doesNotMatch(sharedRuntimeJs, /\$preview\.empty\(\);/); assert.match(dockerJs, /const getFolderPreviewItemsPerRow = \(settings = \{\}\) =>/); assert.match(dockerPreviewActionsScript, /const reconcileDockerPreviewActionButtons = \(\$target,[\s\S]*options = \{\}\) =>/); assert.match(dockerPreviewActionsScript, /ensureDockerPreviewActionSlot/); @@ -86,18 +88,26 @@ test('docker runtime applies preview row layout limits and keeps compact preview assert.match(dockerJs, /const buildDockerPreviewItem = \(\{ entry = \{\}, settings = \{\}, autostart = false \}\) =>/); assert.match(dockerJs, /const shouldHidePreviewStatus = previewStatusMode === 'none';/); assert.match(dockerJs, /\['none', 'hide', 'hidden', 'off', 'false', '0', 'no'\]\.includes\(normalized\)/); - assert.match(dockerJs, /const compactStatusMarkup = shouldHidePreviewStatus[\s\S]*\? ''[\s\S]*: `/); - assert.match(dockerJs, /const bindCompactPreviewDefaultContext = \(\$item, \$sourceRow\) =>/); assert.match(dockerJs, /const buildCompactPreviewDefaultContextItem = \(\$sourceRow, settings = \{\}, autostart = false\) =>/); assert.match(dockerJs, /const \$sourceOuter = \$sourceRow\.find\('td\.ct-name > span\.outer'\)\.first\(\)/); - assert.match(dockerJs, /const bindCompactPreviewDefaultContextProxy = \(\$item\) =>/); - assert.match(dockerJs, /\$menuTrigger\.trigger\('click'\);/); - assert.match(dockerJs, /\$sourceRow\.find\('td\.ct-name > span\.outer > span\.hand'\)\.first\(\)/); - assert.match(dockerJs, /const inlineClick = String\(\$nativeTrigger\.attr\('onclick'\) \|\| ''\)\.trim\(\);/); - assert.match(dockerJs, /const targets = \[/); - assert.match(dockerJs, /\$target\.addClass\('hand'\);/); - assert.match(dockerJs, /\$target\.attr\('onclick', inlineClick\);/); + assert.match(dockerJs, /sanitizeDockerPreviewContextClone\(\$item\);/); + assert.match(dockerJs, /normalizeDockerPreviewStatusMarkup\(\$item\);/); + assert.match(dockerPreviewActionsScript, /const bindDockerPreviewDefaultContextBridge = \(\$item, \$sourceRow, settings = \{\}\) =>/); + assert.match(dockerPreviewActionsScript, /resolveDockerNativePreviewContextTrigger\(\$sourceRow\)/); + assert.match(dockerPreviewActionsScript, /sanitizeDockerPreviewContextClone\(\$item\);/); + assert.match(dockerPreviewActionsScript, /removeAttr\('id onclick oncontextmenu'\)/); + assert.match(dockerPreviewActionsScript, /trigger\.dispatchEvent\(new MouseEventConstructor\('click'/); + assert.match(dockerPreviewActionsScript, /\.on\('keydown\.fvDockerNativePreviewContext'/); + assert.match(dockerPreviewActionsScript, /\['Enter', ' '\]\.includes\(event\.key\)/); + assert.match(dockerPreviewActionsScript, /closest\(dockerPreviewQuickActionSelector\)/); + assert.match(dockerPreviewActionsScript, /PREVIEW_CONTEXT_DIAGNOSTICS_STORAGE_KEY = 'fv\.support\.bundle\.docker\.previewContextBridge\.v1'/); + assert.match(dockerPreviewActionsScript, /const auditDockerPreviewContextBridges = \(\$preview, settings = \{\}\) =>/); + assert.match(dockerPreviewActionsScript, /handlerIntegrityFailures/); + assert.match(dockerPreviewActionsScript, /recordDockerPreviewContextDispatch/); + assert.match(dockerPreviewActionsScript, /getPreviewContextDiagnosticsSnapshot/); + assert.doesNotMatch(dockerJs, /\$target\.attr\('onclick', inlineClick\)/); assert.match(dockerJs, /fv-docker-preview-mode-2 fv-preview-trigger fv-preview-tooltip-proxy/); assert.match(dockerJs, /fv-docker-preview-mode-\$\{previewMode\} fv-preview-trigger fv-preview-tooltip-proxy/); assert.match(dockerJs, /fv-docker-preview-mode-1 fv-preview-trigger fv-preview-tooltip-proxy/); @@ -106,14 +116,16 @@ test('docker runtime applies preview row layout limits and keeps compact preview assert.match(dockerJs, /const appendCompactPreview = \(folderTrId, ctid, autostart, previewEntry, \$sourceRow = null, options = \{\}\) =>/); assert.match(dockerJs, /if \(folder\.settings\.context === 1 && options\.preferNativeDefaultContext !== false\) \{/); assert.match(dockerJs, /compactPreviewItem = buildCompactPreviewDefaultContextItem\(\$sourceRow, folder\.settings, autostart\);/); - assert.match(dockerJs, /bindCompactPreviewDefaultContextProxy\(\$item\);/); - assert.match(dockerJs, /bindCompactPreviewDefaultContext\(\$item, \$sourceRow\);/); + assert.match(dockerJs, /bindDockerPreviewDefaultContextBridge\(\$item, \$sourceRow, folder\.settings\);/); + assert.match(dockerJs, /auditDockerPreviewContextBridges\(\$\(`tr\.folder-id-\$\{id\} div\.folder-preview`\), folder\.settings\);/); assert.match(dockerJs, /\$target\.data\('fvTooltipEnsureInitialized', ensureInitialized\);/); assert.match(dockerJs, /const layoutFolderPreviewRows = \(\$preview, settings = \{\}\) =>/); assert.match(dockerJs, /const applyFolderPreviewLayout = typeof dockerRuntimeShared\.applyFolderPreviewLayout === 'function'/); assert.match(dockerJs, /const flattenPreviewWrappers = typeof dockerRuntimeShared\.flattenPreviewWrappers === 'function'/); assert.match(dockerJs, /const restoreLinearPreviewLayout = typeof dockerRuntimeShared\.restoreLinearPreviewLayout === 'function'/); assert.match(dockerJs, /const finalizePreviewRows = typeof dockerRuntimeShared\.finalizePreviewRows === 'function'/); + assert.match(dockerJs, /\$preview\.children\(\)\.detach\(\);/); + assert.doesNotMatch(dockerJs, /\$preview\.empty\(\);/); assert.match(dockerJs, /renderDockerSingleRowPreview\(/); assert.match(dockerJs, /resilientSingleRowPreview\(1, 'td\.ct-name > span\.outer'/); assert.match(dockerJs, /resilientSingleRowPreview\(3, 'td\.ct-name > span\.outer > span\.inner'/); diff --git a/tests/phase8-test-runner-decomposition.test.mjs b/tests/phase8-test-runner-decomposition.test.mjs index c02f44d1..602aa972 100644 --- a/tests/phase8-test-runner-decomposition.test.mjs +++ b/tests/phase8-test-runner-decomposition.test.mjs @@ -20,8 +20,8 @@ test('Phase 8 runner contract preserves deterministic fixture intent inventory', cwd: rootDir, encoding: 'utf8' }); - assert.match(output, /42 ordered fixture cases/); - assert.equal(contract.intent.fixture.assertionCount, 534); + assert.match(output, /43 ordered fixture cases/); + assert.equal(contract.intent.fixture.assertionCount, 557); }); test('Phase 8 change classification covers every extracted runner family', async () => { diff --git a/tests/preview-border-toggle.test.mjs b/tests/preview-border-toggle.test.mjs index d7aae2e7..fc4c1019 100644 --- a/tests/preview-border-toggle.test.mjs +++ b/tests/preview-border-toggle.test.mjs @@ -164,6 +164,8 @@ test('vm preview renderer honors explicit preview border OFF values', () => { assert.match(vmJs, /const applyFolderPreviewLayout = typeof runtimeShared\.applyFolderPreviewLayout === 'function'/); assert.match(vmJs, /const restoreLinearPreviewLayout = typeof runtimeShared\.restoreLinearPreviewLayout === 'function'/); assert.match(vmJs, /const finalizePreviewRows = typeof runtimeShared\.finalizePreviewRows === 'function'/); + assert.match(vmJs, /\$preview\.children\(\)\.detach\(\);/); + assert.doesNotMatch(vmJs, /\$preview\.empty\(\);/); assert.match(vmJs, /applyPreviewBorderStyle\(previewNode,\s*folder\.settings\);/); assert.match(vmJs, /applyFolderPreviewLayout\(\$preview,\s*folder\.settings\);/); assert.match(vmJs, /layoutFolderPreviewRows\(\$preview,\s*folder\.settings\);/); diff --git a/tests/support-bundle-browser-telemetry.test.mjs b/tests/support-bundle-browser-telemetry.test.mjs index aa274699..e3b98e48 100644 --- a/tests/support-bundle-browser-telemetry.test.mjs +++ b/tests/support-bundle-browser-telemetry.test.mjs @@ -790,6 +790,7 @@ test('support bundle browser telemetry includes persisted docker page snapshot a sanitizedRequestBundleTrace.entries[0].details.hookStates.window.openDocker.notes[0], /^update_container ui-[0-9a-f]{16}\*ui-[0-9a-f]{16}$/ ); + assert.match(telemetryModuleSource, /previewContextBridge:\s*collectDockerPreviewContextDiagnostics\(uiRedactor\)/); const fullBundle = { bundleMeta: { privacyMode: 'full' }, @@ -806,3 +807,46 @@ test('support bundle browser telemetry includes persisted docker page snapshot a ['CloudBerryBackup', 'radarr'] ); }); + +test('support bundle telemetry exports privacy-safe persisted preview context bridge evidence', () => { + const telemetryModule = loadTelemetryModule({}); + const api = telemetryModule.createApi({ + normalizeSupportBundleV2Payload: (bundle) => ({ + ...bundle, + bundleMeta: { ...(bundle.bundleMeta || {}) }, + uiTelemetry: { ...(bundle.uiTelemetry || {}) }, + healthAndHistory: { ...(bundle.healthAndHistory || {}) }, + redactionManifest: { ...(bundle.redactionManifest || {}) } + }), + readClientDiagnosticsStorageRecord: (key) => key === 'docker-preview-context-key' ? { + schemaVersion: 1, + counters: { handlerIntegrityFailures: 0, dispatchAttempts: 2, dispatchSuccesses: 2 }, + rowModes: { '2': { bindings: 6 }, unlimited: { bindings: 6 } }, + rowIndexes: { '1': { bound: 6 }, '2': { bound: 6 } }, + lastEvent: { + type: 'dispatch', + outcome: 'success', + rowMode: 'unlimited', + rowIndex: 2, + triggerSource: 'status', + inputMethod: 'keyboard', + containerName: 'private-container' + } + } : null, + storageKeys: { dockerPreviewContext: 'docker-preview-context-key' } + }); + + const payload = api.collectSupportBundleUiTelemetry({ + bundleMeta: { privacyMode: 'sanitized', redactionSalt: 'test-salt' } + }); + const evidence = payload.uiTelemetry.dockerDiagnostics.previewContextBridge; + + assert.equal(evidence.available, true); + assert.equal(evidence.counters.handlerIntegrityFailures, 0); + assert.equal(evidence.lastEvent.rowMode, 'unlimited'); + assert.equal(evidence.lastEvent.rowIndex, 2); + assert.equal(evidence.lastEvent.triggerSource, 'status'); + assert.equal(evidence.lastEvent.inputMethod, 'keyboard'); + assert.match(evidence.lastEvent.containerName, /^ui-[0-9a-f]{16}$/); + assert.doesNotMatch(JSON.stringify(evidence), /private-container/); +});