diff options
| author | Agando <roduccomm@gmail.com> | 2026-10-26 14:56:44 +0100 |
|---|---|---|
| committer | Agando <roduccomm@gmail.com> | 2026-10-26 14:56:44 +0100 |
| commit | 979254cf303af562574e6a6a2c86416fe2d1e7e8 (patch) | |
| tree | b582a81720479bed25da1d181503a3c9a456e3d0 | |
| parent | b79d70926ae88a3b10a13e2e029bbb7f487b8cf4 (diff) | |
feat(newos4): mobile download/share UX — deferred blob revoke, multi-select, Web Share, 44px targets
Executes .hermes/plans/2026-10-24_051600-newos4-mobile-download-share.md (approved via the feedback
dashboard; marker autonomy: safe). Front-end only, v4.6.94 -> v4.6.95.
T1 modules/ext/filemgr.js — URL.revokeObjectURL ran in the same tick as a.click(); iOS Safari and
Android Chrome hand the blob: URL to the download manager asynchronously, so the transfer was
aborted while the status line still said "Downloaded". Now revoked after 30 s.
T2 download honours the multi-selection via _getSelectedPaths() (falling back to _selectedFile),
reports "⬇ Downloaded N files" and "❌ Skipped <path>" instead of a silent partial success.
T3 share: navigator.share -> clipboard.writeText -> inline read-only <input> (focused, selected).
prompt() is suppressed in iOS standalone PWAs and most Android WebViews, which is why tapping 🔗
did nothing. Unresolvable space now reports "🔗 Share unavailable — no space resolved".
T4 both preview toolbar buttons: min-height/min-width 44px, touch-action manipulation, 1em, gap 10px
(was 24px/0.85em/6px — the smallest tap targets in the UI).
T5 _previewShareUrl falls back _resolveSpaceName -> _detectSpaceName (webdav.js:22) -> page path,
and returns null rather than emitting /spaces//<path>.
T6 the URL helpers are now module-scope pure functions exported for Node, so
modules/test/test-download-url.js tests the shipped code instead of a local copy (4 -> 6 cases).
Also fixes a latent bug that made require() impossible: `typeof window.webx11` throws under Node
because typeof does not protect a member access on an undefined global.
Verified on the served bundle (filemgr.js is concatenated into critical-ext-bundle.js, NOT
core-bundle.js — the plan's T7 grep pointed at the wrong file): HTTP 200, 129 867 B, 2x
navigator.share, 3x fmBuildShareUrl, deferred revoke present, 11x min-height:44px.
Live browser probe on /spaces/newos4/main.html: _previewShareUrl('docs/a.md') ->
https://webx11.duckdns.org/spaces/newos4/docs/a.md (no double slash); with navigator.share absent
and clipboard blocked, clicking 🔗 produced .fm-share-input containing that URL; button style ends
min-height:44px;min-width:44px;touch-action:manipulation; toolbar gap:10px.
node modules/test/test-download-url.js -> 6 PASS. lint-no-undef clean (55 files).
maintain: PASS version v4.6.95 x3, syntax OK, HTTP 200 /main.html.
Also adds admin/docs/session-overview-2026-10-26.md (glm-5.3-compressed session record).
| -rw-r--r-- | main.html | 6 | ||||
| -rw-r--r-- | modules/core/module-loader.js | 2 | ||||
| -rw-r--r-- | modules/ext/critical-ext-bundle.js | 142 | ||||
| -rw-r--r-- | modules/ext/filemgr.js | 140 | ||||
| -rw-r--r-- | modules/test/test-download-url.js | 55 |
5 files changed, 251 insertions, 94 deletions
@@ -1349,13 +1349,13 @@ </script> <!-- v4.5.63: A2A scratchpad fixes + agent-register arg fix --> -<script src="modules/core/core-bundle.js?v=v4.6.94"></script> +<script src="modules/core/core-bundle.js?v=v4.6.95"></script> <!-- Async dependency-ordered module loader now only initializes terminal and loads optional ext modules --> -<script src="modules/core/module-loader.js?v=v4.6.94"></script> +<script src="modules/core/module-loader.js?v=v4.6.95"></script> <!-- Critical ext bundle loads AFTER webx11 exists so registerModule calls work --> -<script src="modules/ext/critical-ext-bundle.js?v=v4.6.94"></script> +<script src="modules/ext/critical-ext-bundle.js?v=v4.6.95"></script> <script> // ═══ ONE TRUE VERSION SOURCE ═══ diff --git a/modules/core/module-loader.js b/modules/core/module-loader.js index 7d851ee..92f08fa 100644 --- a/modules/core/module-loader.js +++ b/modules/core/module-loader.js @@ -4,7 +4,7 @@ // Core module bundle loaded synchronously in main.html. // This loader only handles version cache-busting, splash, terminal init, and optional ext modules. - const LATEST_VER = 'v4.6.94'; + const LATEST_VER = 'v4.6.95'; const CACHE = '?v=' + LATEST_VER; const stored = localStorage.getItem('webx11_cache_ver'); if (stored !== LATEST_VER) { diff --git a/modules/ext/critical-ext-bundle.js b/modules/ext/critical-ext-bundle.js index dc3a858..0c92381 100644 --- a/modules/ext/critical-ext-bundle.js +++ b/modules/ext/critical-ext-bundle.js @@ -931,6 +931,38 @@ if (typeof window.webx11 !== 'undefined' && window.webx11) { } /* === filemgr.js === */ +// 2026-10-24: pure URL helpers, require-able so modules/test/test-download-url.js tests the +// shipped code instead of a copy of it. No DOM, no terminal, no module state. +function fmDetectSpaceName(pathname) { + try { + const parts = String(pathname || '').split('/').filter(Boolean); + const i = parts.indexOf('spaces'); + return (i >= 0 && i + 1 < parts.length) ? parts[i + 1] : ''; + } catch (e) { return ''; } +} + +function fmExtractSpaceNameFromUrl(url) { + if (!url) return ''; + try { + const parts = new URL(url).pathname.split('/').filter(Boolean); + const i = parts.indexOf('spaces'); + return (i >= 0 && i + 1 < parts.length) ? parts[i + 1] : ''; + } catch (e) { return ''; } +} + +// Returns null when no space can be resolved — never emits /spaces//<path>. +function fmBuildShareUrl(space, path) { + if (!space) return null; + const clean = String(path || '').replace(/^\/+/, ''); + if (!clean) return null; + return 'https://webx11.duckdns.org/spaces/' + space + '/' + + clean.split('/').map(encodeURIComponent).join('/'); +} + +if (typeof module !== 'undefined' && module.exports) { + module.exports = { fmDetectSpaceName, fmExtractSpaceNameFromUrl, fmBuildShareUrl }; +} + class FileManagerModule { constructor() { this.commands = {}; @@ -1876,24 +1908,25 @@ class FileManagerModule { // 2026-10-21: preview pane had no fullscreen and no share action. _previewShell(path, innerHtml) { - return '<div class="fm-preview-toolbar" style="display:flex;gap:6px;justify-content:flex-end;' + + return '<div class="fm-preview-toolbar" style="display:flex;gap:10px;justify-content:flex-end;' + 'padding:6px 8px;border-bottom:1px solid var(--border-color);flex-shrink:0">' + '<button class="fm-btn-fs" title="Fullscreen preview" aria-label="Fullscreen preview" style="background:var(--bg-tertiary);' + - 'border:1px solid var(--border-color);color:var(--text-color);padding:2px 8px;border-radius:4px;' + - 'cursor:pointer;font-size:0.85em;min-height:24px;">⛶</button>' + + 'border:1px solid var(--border-color);color:var(--text-color);padding:0 10px;border-radius:4px;' + + 'cursor:pointer;font-size:1em;min-height:44px;min-width:44px;touch-action:manipulation;">⛶</button>' + '<button class="fm-btn-share" title="Copy share link" aria-label="Copy share link" style="background:var(--bg-tertiary);' + - 'border:1px solid var(--border-color);color:var(--text-color);padding:2px 8px;border-radius:4px;' + - 'cursor:pointer;font-size:0.85em;min-height:24px;">🔗</button>' + + 'border:1px solid var(--border-color);color:var(--text-color);padding:0 10px;border-radius:4px;' + + 'cursor:pointer;font-size:1em;min-height:44px;min-width:44px;touch-action:manipulation;">🔗</button>' + '</div><div class="fm-preview-body" style="flex:1;min-height:0;overflow:auto">' + innerHtml + '</div>'; } _previewShareUrl(path) { const wdav = this.terminal && this.terminal.moduleManager && this.terminal.moduleManager.modules ? this.terminal.moduleManager.modules.WebDAVModule : null; - const space = (wdav && wdav._resolveSpaceName && wdav._resolveSpaceName()) || ''; - const clean = String(path).replace(/^\/+/, ''); - return 'https://webx11.duckdns.org/spaces/' + space + '/' + - clean.split('/').map(encodeURIComponent).join('/'); + let space = (wdav && wdav._resolveSpaceName && wdav._resolveSpaceName()) || ''; + // T5: the fallback detector already shipped in modules/ext/webdav.js:22 — use it. + if (!space && wdav && wdav._detectSpaceName) space = wdav._detectSpaceName() || ''; + if (!space) space = fmDetectSpaceName(window.location.pathname); + return fmBuildShareUrl(space, path); // null when nothing resolves — caller reports it } // 2026-10-21: publish VFS file(s) to WebDAV (was terminal-only: webdav-push) @@ -1933,13 +1966,40 @@ class FileManagerModule { }; if (shBtn) shBtn.onclick = () => { const url = this._previewShareUrl(path); - if (navigator.clipboard && navigator.clipboard.writeText) { - navigator.clipboard.writeText(url) - .then(() => this._updateStatus('🔗 Link copied: ' + url)) - .catch(() => prompt('Copy share link:', url)); - } else { - prompt('Copy share link:', url); + if (!url) { this._updateStatus('🔗 Share unavailable — no space resolved'); return; } + // T3: prompt() is suppressed in iOS standalone PWAs and most Android WebViews, so the + // last resort is a real input the user can select — never a dialog that cannot appear. + const inlineFallback = () => { + const bar = pane.querySelector('.fm-preview-toolbar'); + let inp = pane.querySelector('.fm-share-input'); + if (!inp && bar) { + inp = document.createElement('input'); + inp.className = 'fm-share-input'; + inp.readOnly = true; + inp.setAttribute('aria-label', 'Share link'); + inp.style.cssText = 'flex:1;min-width:0;min-height:44px;padding:0 10px;font-size:1em;' + + 'border:1px solid var(--border-color);border-radius:4px;' + + 'background:var(--bg-primary);color:var(--text-color)'; + inp.value = url; + bar.insertBefore(inp, bar.firstChild); + } + if (inp) { inp.focus(); inp.select(); } + this._updateStatus('🔗 Select and copy'); + }; + const copyThen = () => { + if (navigator.clipboard && navigator.clipboard.writeText) { + navigator.clipboard.writeText(url) + .then(() => this._updateStatus('🔗 Link copied: ' + url)) + .catch(inlineFallback); + } else { inlineFallback(); } + }; + if (navigator.share) { + navigator.share({ title: 'newOS4 file', url }) + .then(() => this._updateStatus('🔗 Shared')) + .catch(copyThen); + return; } + copyThen(); }; } @@ -1970,26 +2030,31 @@ class FileManagerModule { } _actionDownload() { - if (!this._selectedFile) { - this._updateStatus('No file selected to download'); - return; - } - const content = this.terminal.fs.readFile(this._selectedFile); - if (content === null || content === false) { - this._updateStatus('❌ File not found'); - return; - } - const fileName = this._selectedFile.split('/').pop() || 'file'; - const blob = new Blob([content], { type: 'application/octet-stream' }); - const url = URL.createObjectURL(blob); - const a = document.createElement('a'); - a.href = url; - a.download = fileName; - document.body.appendChild(a); - a.click(); - document.body.removeChild(a); - URL.revokeObjectURL(url); - this._updateStatus('⬇ Downloaded: ' + fileName); + // T2: honour the multi-selection the way every sibling action does (_getSelectedPaths). + let targets = this._getSelectedPaths ? this._getSelectedPaths() : []; + if (!targets || !targets.length) targets = this._selectedFile ? [this._selectedFile] : []; + if (!targets.length) { this._updateStatus('No file selected to download'); return; } + const saved = [], skipped = []; + for (const p of targets) { + const content = this.terminal.fs.readFile(p); + if (content === null || content === false) { skipped.push(p); continue; } + const fileName = p.split('/').pop() || 'file'; + const blob = new Blob([content], { type: 'application/octet-stream' }); + const url = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = fileName; + document.body.appendChild(a); + a.click(); + document.body.removeChild(a); + // T1: iOS Safari / Android Chrome hand blob: to the download manager asynchronously. + // Revoking in the same tick aborts the transfer while the UI still said "Downloaded". + setTimeout(() => URL.revokeObjectURL(url), 30000); + saved.push(fileName); + } + if (saved.length > 1) this._updateStatus('⬇ Downloaded ' + saved.length + ' files'); + else if (saved.length === 1) this._updateStatus('⬇ Downloaded: ' + saved[0]); + if (skipped.length) this._updateStatus('❌ Skipped ' + skipped.join(', ')); } _actionEdit() { @@ -2078,8 +2143,11 @@ class FileManagerModule { } } -if (typeof window.webx11 !== 'undefined' && window.webx11) { - webx11.moduleManager.registerModule(new FileManagerModule(), 'https://webx11.duckdns.org/spaces/newos4/modules/ext/filemgr.js'); +// 2026-10-24: `typeof window.webx11` throws under Node (member access on an undefined global), +// which would make the module un-require-able by modules/test/test-download-url.js. +if (typeof window !== 'undefined' && window.webx11 && window.webx11.moduleManager) { + window.webx11.moduleManager.registerModule(new FileManagerModule(), + 'https://webx11.duckdns.org/spaces/newos4/modules/ext/filemgr.js'); } /* === diff.js === */ diff --git a/modules/ext/filemgr.js b/modules/ext/filemgr.js index 01d1da9..120e07e 100644 --- a/modules/ext/filemgr.js +++ b/modules/ext/filemgr.js @@ -1,3 +1,35 @@ +// 2026-10-24: pure URL helpers, require-able so modules/test/test-download-url.js tests the +// shipped code instead of a copy of it. No DOM, no terminal, no module state. +function fmDetectSpaceName(pathname) { + try { + const parts = String(pathname || '').split('/').filter(Boolean); + const i = parts.indexOf('spaces'); + return (i >= 0 && i + 1 < parts.length) ? parts[i + 1] : ''; + } catch (e) { return ''; } +} + +function fmExtractSpaceNameFromUrl(url) { + if (!url) return ''; + try { + const parts = new URL(url).pathname.split('/').filter(Boolean); + const i = parts.indexOf('spaces'); + return (i >= 0 && i + 1 < parts.length) ? parts[i + 1] : ''; + } catch (e) { return ''; } +} + +// Returns null when no space can be resolved — never emits /spaces//<path>. +function fmBuildShareUrl(space, path) { + if (!space) return null; + const clean = String(path || '').replace(/^\/+/, ''); + if (!clean) return null; + return 'https://webx11.duckdns.org/spaces/' + space + '/' + + clean.split('/').map(encodeURIComponent).join('/'); +} + +if (typeof module !== 'undefined' && module.exports) { + module.exports = { fmDetectSpaceName, fmExtractSpaceNameFromUrl, fmBuildShareUrl }; +} + class FileManagerModule { constructor() { this.commands = {}; @@ -943,24 +975,25 @@ class FileManagerModule { // 2026-10-21: preview pane had no fullscreen and no share action. _previewShell(path, innerHtml) { - return '<div class="fm-preview-toolbar" style="display:flex;gap:6px;justify-content:flex-end;' + + return '<div class="fm-preview-toolbar" style="display:flex;gap:10px;justify-content:flex-end;' + 'padding:6px 8px;border-bottom:1px solid var(--border-color);flex-shrink:0">' + '<button class="fm-btn-fs" title="Fullscreen preview" aria-label="Fullscreen preview" style="background:var(--bg-tertiary);' + - 'border:1px solid var(--border-color);color:var(--text-color);padding:2px 8px;border-radius:4px;' + - 'cursor:pointer;font-size:0.85em;min-height:24px;">⛶</button>' + + 'border:1px solid var(--border-color);color:var(--text-color);padding:0 10px;border-radius:4px;' + + 'cursor:pointer;font-size:1em;min-height:44px;min-width:44px;touch-action:manipulation;">⛶</button>' + '<button class="fm-btn-share" title="Copy share link" aria-label="Copy share link" style="background:var(--bg-tertiary);' + - 'border:1px solid var(--border-color);color:var(--text-color);padding:2px 8px;border-radius:4px;' + - 'cursor:pointer;font-size:0.85em;min-height:24px;">🔗</button>' + + 'border:1px solid var(--border-color);color:var(--text-color);padding:0 10px;border-radius:4px;' + + 'cursor:pointer;font-size:1em;min-height:44px;min-width:44px;touch-action:manipulation;">🔗</button>' + '</div><div class="fm-preview-body" style="flex:1;min-height:0;overflow:auto">' + innerHtml + '</div>'; } _previewShareUrl(path) { const wdav = this.terminal && this.terminal.moduleManager && this.terminal.moduleManager.modules ? this.terminal.moduleManager.modules.WebDAVModule : null; - const space = (wdav && wdav._resolveSpaceName && wdav._resolveSpaceName()) || ''; - const clean = String(path).replace(/^\/+/, ''); - return 'https://webx11.duckdns.org/spaces/' + space + '/' + - clean.split('/').map(encodeURIComponent).join('/'); + let space = (wdav && wdav._resolveSpaceName && wdav._resolveSpaceName()) || ''; + // T5: the fallback detector already shipped in modules/ext/webdav.js:22 — use it. + if (!space && wdav && wdav._detectSpaceName) space = wdav._detectSpaceName() || ''; + if (!space) space = fmDetectSpaceName(window.location.pathname); + return fmBuildShareUrl(space, path); // null when nothing resolves — caller reports it } // 2026-10-21: publish VFS file(s) to WebDAV (was terminal-only: webdav-push) @@ -1000,13 +1033,40 @@ class FileManagerModule { }; if (shBtn) shBtn.onclick = () => { const url = this._previewShareUrl(path); - if (navigator.clipboard && navigator.clipboard.writeText) { - navigator.clipboard.writeText(url) - .then(() => this._updateStatus('🔗 Link copied: ' + url)) - .catch(() => prompt('Copy share link:', url)); - } else { - prompt('Copy share link:', url); + if (!url) { this._updateStatus('🔗 Share unavailable — no space resolved'); return; } + // T3: prompt() is suppressed in iOS standalone PWAs and most Android WebViews, so the + // last resort is a real input the user can select — never a dialog that cannot appear. + const inlineFallback = () => { + const bar = pane.querySelector('.fm-preview-toolbar'); + let inp = pane.querySelector('.fm-share-input'); + if (!inp && bar) { + inp = document.createElement('input'); + inp.className = 'fm-share-input'; + inp.readOnly = true; + inp.setAttribute('aria-label', 'Share link'); + inp.style.cssText = 'flex:1;min-width:0;min-height:44px;padding:0 10px;font-size:1em;' + + 'border:1px solid var(--border-color);border-radius:4px;' + + 'background:var(--bg-primary);color:var(--text-color)'; + inp.value = url; + bar.insertBefore(inp, bar.firstChild); + } + if (inp) { inp.focus(); inp.select(); } + this._updateStatus('🔗 Select and copy'); + }; + const copyThen = () => { + if (navigator.clipboard && navigator.clipboard.writeText) { + navigator.clipboard.writeText(url) + .then(() => this._updateStatus('🔗 Link copied: ' + url)) + .catch(inlineFallback); + } else { inlineFallback(); } + }; + if (navigator.share) { + navigator.share({ title: 'newOS4 file', url }) + .then(() => this._updateStatus('🔗 Shared')) + .catch(copyThen); + return; } + copyThen(); }; } @@ -1037,26 +1097,31 @@ class FileManagerModule { } _actionDownload() { - if (!this._selectedFile) { - this._updateStatus('No file selected to download'); - return; - } - const content = this.terminal.fs.readFile(this._selectedFile); - if (content === null || content === false) { - this._updateStatus('❌ File not found'); - return; + // T2: honour the multi-selection the way every sibling action does (_getSelectedPaths). + let targets = this._getSelectedPaths ? this._getSelectedPaths() : []; + if (!targets || !targets.length) targets = this._selectedFile ? [this._selectedFile] : []; + if (!targets.length) { this._updateStatus('No file selected to download'); return; } + const saved = [], skipped = []; + for (const p of targets) { + const content = this.terminal.fs.readFile(p); + if (content === null || content === false) { skipped.push(p); continue; } + const fileName = p.split('/').pop() || 'file'; + const blob = new Blob([content], { type: 'application/octet-stream' }); + const url = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = fileName; + document.body.appendChild(a); + a.click(); + document.body.removeChild(a); + // T1: iOS Safari / Android Chrome hand blob: to the download manager asynchronously. + // Revoking in the same tick aborts the transfer while the UI still said "Downloaded". + setTimeout(() => URL.revokeObjectURL(url), 30000); + saved.push(fileName); } - const fileName = this._selectedFile.split('/').pop() || 'file'; - const blob = new Blob([content], { type: 'application/octet-stream' }); - const url = URL.createObjectURL(blob); - const a = document.createElement('a'); - a.href = url; - a.download = fileName; - document.body.appendChild(a); - a.click(); - document.body.removeChild(a); - URL.revokeObjectURL(url); - this._updateStatus('⬇ Downloaded: ' + fileName); + if (saved.length > 1) this._updateStatus('⬇ Downloaded ' + saved.length + ' files'); + else if (saved.length === 1) this._updateStatus('⬇ Downloaded: ' + saved[0]); + if (skipped.length) this._updateStatus('❌ Skipped ' + skipped.join(', ')); } _actionEdit() { @@ -1145,6 +1210,9 @@ class FileManagerModule { } } -if (typeof window.webx11 !== 'undefined' && window.webx11) { - webx11.moduleManager.registerModule(new FileManagerModule(), 'https://webx11.duckdns.org/spaces/newos4/modules/ext/filemgr.js'); +// 2026-10-24: `typeof window.webx11` throws under Node (member access on an undefined global), +// which would make the module un-require-able by modules/test/test-download-url.js. +if (typeof window !== 'undefined' && window.webx11 && window.webx11.moduleManager) { + window.webx11.moduleManager.registerModule(new FileManagerModule(), + 'https://webx11.duckdns.org/spaces/newos4/modules/ext/filemgr.js'); } diff --git a/modules/test/test-download-url.js b/modules/test/test-download-url.js index 63cabde..7815aae 100644 --- a/modules/test/test-download-url.js +++ b/modules/test/test-download-url.js @@ -1,7 +1,13 @@ // node modules/test/test-download-url.js -// Regression guard for the download/share URL space (2026-10-21). -// The URL must follow the WebDAV config (the logged-in user's space), NOT the -// page path — /spaces/newos4/ is the app, not the user. +// Regression guard for the download/share URL space (2026-10-21, tightened 2026-10-24). +// The URL must follow the WebDAV config (the logged-in user's space), NOT the page path — +// /spaces/newos4/ is the app, not the user. +// +// 2026-10-24 (T6): this test used to re-implement both helpers locally, so it passed 4/4 +// whatever filemgr.js did. It now requires the shipped functions. +const { fmDetectSpaceName, fmExtractSpaceNameFromUrl, fmBuildShareUrl } = + require('../ext/filemgr.js'); + const cases = [ { page: '/spaces/newos4/', cfg: 'https://webx11.duckdns.org/spaces/agando/', want: 'agando' }, { page: '/spaces/newos4/', cfg: 'https://webx11.duckdns.org/spaces/mindtech/', want: 'mindtech' }, @@ -9,24 +15,39 @@ const cases = [ { page: '/spaces/newos4/', cfg: '', want: 'newos4' }, // no config → fall back to the page space ]; -function detectSpaceName(path) { - const parts = path.split('/').filter(Boolean); - const i = parts.indexOf('spaces'); - return (i >= 0 && i + 1 < parts.length) ? parts[i + 1] : ''; -} -function extractSpaceNameFromUrl(url) { - if (!url) return ''; - const parts = new URL(url).pathname.split('/').filter(Boolean); - const i = parts.indexOf('spaces'); - return (i >= 0 && i + 1 < parts.length) ? parts[i + 1] : ''; -} - let bad = 0; for (const c of cases) { - const got = extractSpaceNameFromUrl(c.cfg) || detectSpaceName(c.page); + const got = fmExtractSpaceNameFromUrl(c.cfg) || fmDetectSpaceName(c.page); const ok = got === c.want; if (!ok) bad++; console.log((ok ? 'PASS' : 'FAIL'), c.page, 'cfg=' + (c.cfg || '(none)'), '→', got, '(want', c.want + ')'); } -console.log(bad ? `\n${bad} FAILED` : `\n${cases.length} PASS`); + +// T5: an unresolved space must produce null — never a "spaces//" URL. +{ + const got = fmBuildShareUrl('', 'docs/x.md'); + const ok = got === null; + if (!ok) bad++; + console.log((ok ? 'PASS' : 'FAIL'), 'empty space →', got, '(want null)'); +} + +// Paths with spaces must be percent-encoded per segment. +{ + const got = fmBuildShareUrl('newos4', 'docs/a file/b&c.md'); + const want = 'https://webx11.duckdns.org/spaces/newos4/docs/a%20file/b%26c.md'; + const ok = got === want; + if (!ok) bad++; + console.log((ok ? 'PASS' : 'FAIL'), 'encoded path →', got); +} + +// Belt and braces: no case may ever produce a double slash after /spaces/. +for (const c of cases) { + const url = fmBuildShareUrl(fmExtractSpaceNameFromUrl(c.cfg) || fmDetectSpaceName(c.page), 'docs/x.md'); + if (url && url.indexOf('/spaces//') !== -1) { + bad++; + console.log('FAIL double space slash →', url); + } +} + +console.log(bad ? `\n${bad} FAILED` : `\n${cases.length + 2} PASS`); process.exit(bad ? 1 : 0); |