// node modules/test/test-webdav-sizes.js // test-webdav-sizes: two WebDAV defects that both lied to the user. // // (a) webdav-ls sizes. nginx renders __list__ with `autoindex_exact_size off`, // so the HTML carries humanised sizes ('21K', '45K', '1.2M'). _parseAutoindex // did `parseInt(sizeStr) || 0`, which drops the suffix: '21K' -> 21 bytes, and // _formatSize() then printed '21 B' for a 21 397-byte file. The PROPFIND path // already gets exact bytes from getcontentlength and must stay exact. // (b) webdav-pull into a missing local directory. It called fs.writeFile() and // ignored the return value — virtual-fs returns false when the parent chain is // absent — then printed 'Pulled: ... (N bytes)' for a write that never happened. // // Run: node modules/test/test-webdav-sizes.js // Set WEBDAV_SRC= to point the harness at another copy of webdav.js // (used to prove the test is RED against the pre-fix file). const fs = require('fs'), path = require('path'); const SRC = process.env.WEBDAV_SRC || path.join(__dirname, '..', 'ext', 'webdav.js'); // --- browser stubs ------------------------------------------------------- const store = {}; global.localStorage = { getItem: k => (k in store ? store[k] : null), setItem: (k, v) => { store[k] = String(v); }, removeItem: k => { delete store[k]; } }; global.window = global; global.location = { pathname: '/spaces/chatstudio/newos4/' }; global.document = { cookie: '', addEventListener: () => {}, removeEventListener: () => {}, getElementById: () => null, querySelector: () => null, createElement: () => ({ style: {}, appendChild: () => {}, setAttribute: () => {}, addEventListener: () => {}, remove: () => {} }), body: { appendChild: () => {} } }; global.fetch = async () => ({ ok: false, status: 401, statusText: 'Unauthorized', text: async () => '', json: async () => ({}) }); // The PROPFIND path builds a DOM. Feed it pre-cooked response objects as JSON: // parseFromString(JSON) -> { querySelectorAll() } is all _parsePropfind uses. function fakeDoc(xml) { const rows = JSON.parse(xml); return { querySelectorAll: () => rows.map(r => ({ querySelector: (sel) => ({ textContent: sel.indexOf('href') >= 0 ? (r.href || '') : sel.indexOf('collection') >= 0 ? (r.collection || '') : (r.size || '') }) })) }; } global.DOMParser = function () { this.parseFromString = fakeDoc; }; let WebDAVModule; try { WebDAVModule = new Function(fs.readFileSync(SRC, 'utf8') + '\nreturn WebDAVModule;')(); } catch (e) { console.log('FAIL could not load ' + SRC + ' — ' + e.message); process.exit(1); } if (typeof WebDAVModule !== 'function') { console.log('FAIL webdav.js did not expose WebDAVModule (got ' + typeof WebDAVModule + ')'); process.exit(1); } let fails = 0, passes = 0; function ok(name, cond, detail) { if (cond) { passes++; return; } fails++; console.log('FAIL ' + name + (detail !== undefined ? ' — ' + detail : '')); } // A VFS stand-in with virtual-fs's real semantics: writeFile() refuses to create // a file whose parent directory does not exist (core/virtual-fs.js _resolvePath). function makeVfs(opts) { opts = opts || {}; const dirs = new Set(['/']); const files = {}; const calls = []; const norm = p => '/' + String(p).split('/').filter(Boolean).join('/'); const parentOf = p => norm(p).replace(/\/[^/]*$/, '') || '/'; return { files, calls, mkdirRecursive(p) { calls.push('mkdir:' + p); if (opts.mkdirFails) return false; let cur = ''; for (const seg of String(p).split('/').filter(Boolean)) { cur += '/' + seg; dirs.add(cur); } return true; }, writeFile(p, c) { calls.push('write:' + p); if (opts.writeAlwaysFails) return false; if (!dirs.has(parentOf(p))) return false; // missing parent -> false files[norm(p)] = c; return true; }, readFile(p) { return (norm(p) in files) ? files[norm(p)] : null; } }; } function makeModule(vfs) { const m = new WebDAVModule(); const out = []; m.terminal = { print: (msg, cls) => out.push({ msg: String(msg), cls: cls || '' }), fs: vfs }; m.config = { url: 'https://webx11.duckdns.org/spaces/chatstudio/', username: 'chatstudio', password: 'tok' }; m.registerCommands(); return { m, out }; } const said = (out, re) => out.some(o => re.test(o.msg)); (async () => { // ---------------- (a) humanised autoindex sizes ---------------- const dir = makeModule(makeVfs()).m; // Exactly the shape nginx emits with autoindex_exact_size off: link, date, size. const html = '

Index of /


'
        + '../                                          02-Nov-2026 09:13    -\n'
        + 'CHANGELOG.md                     02-Nov-2026 09:14   21K\n'
        + 'downloads/                           02-Nov-2026 09:14    -\n'
        + 'small.txt                             02-Nov-2026 09:15   512\n'
        + 'big.bin                                 02-Nov-2026 09:16   1.2M\n'
        + 'huge.bin                               02-Nov-2026 09:17   2G\n'
        + 'memgraph.html                     02-Nov-2026 09:18   45K\n'
        + '

'; const entries = dir._parseAutoindex(html); const byName = {}; for (const e of entries) byName[e.name] = e; ok('A1 autoindex parsed 6 entries (not the 0-byte fallback)', entries.length === 6, entries.length + ': ' + entries.map(e => e.name + '=' + e.size).join(',')); ok('A0 no entry fell back to a 0 size', entries.every(e => e.type === 'dir' || e.size > 0), entries.map(e => e.name + '=' + e.size).join(',')); ok('A1b 21K is 21504 bytes, not 21', byName['CHANGELOG.md'] && byName['CHANGELOG.md'].size === 21 * 1024, byName['CHANGELOG.md'] && byName['CHANGELOG.md'].size); ok('A2 45K is 46080 bytes, not 45', byName['memgraph.html'] && byName['memgraph.html'].size === 45 * 1024, byName['memgraph.html'] && byName['memgraph.html'].size); ok('A3 1.2M is 1.2*1024*1024', byName['big.bin'] && byName['big.bin'].size === Math.round(1.2 * 1024 * 1024), byName['big.bin'] && byName['big.bin'].size); ok('A4 2G is 2*1024^3', byName['huge.bin'] && byName['huge.bin'].size === 2 * 1024 * 1024 * 1024, byName['huge.bin'] && byName['huge.bin'].size); ok('A5 plain numeric size stays exact', byName['small.txt'] && byName['small.txt'].size === 512, byName['small.txt'] && byName['small.txt'].size); ok('A6 directories keep size 0', byName['downloads'] && byName['downloads'].size === 0, byName['downloads'] && byName['downloads'].size); // The user-visible symptom: webdav-ls printed '21 B' for a 21 KB file. ok('A7 _formatSize(21K) no longer renders bytes', dir._formatSize(byName['CHANGELOG.md'].size) === '21.0 KB', dir._formatSize(byName['CHANGELOG.md'].size)); ok('A8 webdav-ls line shows KB not B', dir._formatSize(45 * 1024) === '45.0 KB', dir._formatSize(45 * 1024)); // Suffix parser edge cases (case-insensitive, optional i/B, junk, missing). const psize = s => { try { return dir._parseAutoindexSize(s); } catch (e) { return NaN; } }; ok('A9 lowercase k', psize('21k') === 21 * 1024, psize('21k')); ok('A10 "1KiB" style', psize('1KiB') === 1024, psize('1KiB')); ok('A11 integer KB', psize('1KB') === 1024, psize('1KB')); ok('A12 \'-\' / junk -> 0', psize('-') === 0 && psize('garbage') === 0, psize('-') + '/' + psize('garbage')); // PROPFIND must NOT regress: exact getcontentlength bytes, untouched. const pf = dir._parsePropfind(JSON.stringify([ { href: '/spaces/chatstudio/newos4/', collection: 'collection', size: '' }, { href: '/spaces/chatstudio/newos4/CHANGELOG.md', collection: '', size: '21397' } ])); const pfe = pf.filter(e => e.name === 'CHANGELOG.md')[0]; ok('A13 PROPFIND keeps exact 21397 bytes', !!pfe && pfe.size === 21397, pfe && pfe.size); // ---------------- (b) webdav-pull into a missing dir ---------------- // B1: the reported bug — pull to notes/deep/a.txt with no such local dirs. { const vfs = makeVfs(); const { m, out } = makeModule(vfs); m._webdavRequest = async () => ({ ok: true, text: async () => 'HELLO-WEBDAV' }); await m.commands['webdav-pull'](['notes/deep/a.txt', 'notes/deep/a.txt']); ok('B1 pull into missing dir succeeds and saves the file', said(out, /^Pulled: /) && vfs.readFile('/notes/deep/a.txt') === 'HELLO-WEBDAV', out.map(o => o.msg).join(' | ') + ' | read=' + JSON.stringify(vfs.readFile('/notes/deep/a.txt'))); ok('B2 the file really exists locally', vfs.readFile('/notes/deep/a.txt') === 'HELLO-WEBDAV', JSON.stringify(vfs.readFile('/notes/deep/a.txt'))); ok('B3 parent chain was created before the write', vfs.calls.indexOf('mkdir:notes/deep') >= 0 && vfs.calls.indexOf('mkdir:notes/deep') < vfs.calls.indexOf('write:notes/deep/a.txt'), vfs.calls.join(' > ')); ok('B4 byte count is the fetched length', said(out, /\(12 bytes\)$/), out.map(o => o.msg).join(' | ')); } // B5: write still fails (e.g. path is a directory) -> error, NO success line. { const vfs = makeVfs({ writeAlwaysFails: true }); const { m, out } = makeModule(vfs); m._webdavRequest = async () => ({ ok: true, text: async () => 'DATA' }); await m.commands['webdav-pull'](['x/y.txt', 'x/y.txt']); ok('B5 refused write reports failure, not success', !said(out, /^Pulled: /) && said(out, /^Pull failed: /), out.map(o => o.msg).join(' | ')); } // B6: cannot even create the directory -> error, NO success line. { const vfs = makeVfs({ mkdirFails: true }); const { m, out } = makeModule(vfs); m._webdavRequest = async () => ({ ok: true, text: async () => 'DATA' }); await m.commands['webdav-pull'](['x/y.txt', 'x/y.txt']); ok('B6 mkdir failure reports failure, not success', !said(out, /^Pulled: /) && said(out, /^Pull failed: /), out.map(o => o.msg).join(' | ')); ok('B7 nothing was written when mkdir failed', vfs.calls.indexOf('write:x/y.txt') === -1, vfs.calls.join(' > ')); } // B8: plain filename in an existing root dir still works (no spurious mkdir). { const vfs = makeVfs(); const { m, out } = makeModule(vfs); m._webdavRequest = async () => ({ ok: true, text: async () => 'ROOTFILE' }); await m.commands['webdav-pull'](['file.txt']); ok('B8 root-level pull still works', said(out, /^Pulled: /) && vfs.readFile('/file.txt') === 'ROOTFILE', out.map(o => o.msg).join(' | ')); ok('B9 no mkdir for a root-level file', vfs.calls.filter(c => c.startsWith('mkdir:')).length === 0, vfs.calls.join(' > ')); } // A14. the parser accepts G, so the formatter must render GB (it printed '3072.0 MB' // for a 3 GB file) and must not print 'NaN MB' for a missing size. ok('A14a 2 GiB renders as GB', WebDAVModule.prototype._formatSize.call(null, 2 * 1024 * 1024 * 1024) === '2.0 GB', WebDAVModule.prototype._formatSize.call(null, 2 * 1024 * 1024 * 1024)); ok('A14b undefined does not render NaN', WebDAVModule.prototype._formatSize.call(null, undefined) === '—', WebDAVModule.prototype._formatSize.call(null, undefined)); ok('A14d null renders as unknown, not 0 B', WebDAVModule.prototype._formatSize.call(null, null) === '—', WebDAVModule.prototype._formatSize.call(null, null)); ok('A14c 0 bytes is still B', WebDAVModule.prototype._formatSize.call(null, 0) === '0 B', WebDAVModule.prototype._formatSize.call(null, 0)); console.log('test-webdav-sizes: ' + passes + ' passed, ' + fails + ' failed'); process.exit(fails ? 1 : 0); })().catch(e => { console.log('FAIL harness threw — ' + (e && e.stack || e)); process.exit(1); });