aboutsummaryrefslogtreecommitdiff
path: root/modules/test/test-webdav-sizes.js
diff options
context:
space:
mode:
authorAgando <roduccomm@gmail.com>2026-11-02 21:05:58 +0100
committerAgando <roduccomm@gmail.com>2026-11-02 21:05:58 +0100
commitc2f2ea62503f81c07f4d36d7c3a798b91146153e (patch)
tree6f20a4437c75a7f27d4b01f4dd2fe413e4bcfcd1 /modules/test/test-webdav-sizes.js
parentb339bc13199eb9326307905dd635c56974b55f53 (diff)
fix(newos4): WebDAV listing sizes + pull, and five MemoryGraph defects
Closes STEER-20261102-205416-facd and STEER-20261102-204031-f82a. WebDAV (modules/ext/webdav.js): - listings showed "21 B" for a 21 KB file: nginx emits humanised sizes (autoindex_exact_size off, vhost :412) and _parseAutoindex did parseInt("21K"). _parseAutoindexSize() handles K/M/G, decimals, lowercase and "-"; PROPFIND's exact getcontentlength path is untouched. - webdav-pull into a missing local directory printed "Pulled: ... (N bytes)" and wrote nothing: it ignored fs.writeFile's false return (virtual-fs.js:561). It now creates the parent chain, checks the write, and only reports success after a confirmed write. The byte count uses TextEncoder, not .length (a Cyrillic file under-reported ~2x). - _formatSize gained the GB branch the parser already accepted (3 GB printed "3072.0 MB") and no longer prints "NaN MB" for a missing size. MemoryGraph (modules/core/module-memory-graph.js): - addEdge averages weights, so every repeat pulled a strong edge toward the call site's constant (0.9 -> 0.7 -> 0.6). Now Math.max, with decay left to applyDecay(); prune() untouched. - task ids were `task-${Date.now()}`, so re-submitting a task never deduped: now a hash of agent + normalised text. - recordFileAccess no longer records the graph's own /.memory_graph/ store. - failed tool calls were weighted 0.8 vs success 0.5, so showStats ranked failures as strongest: now 0.2. Legacy 0.8 failures are capped on load. - concept ids keep punctuation ("concept:(v2!),"): slugged, and the slug uses \p{L}\p{N} so Cyrillic/CJK/accented text still yields nodes -- an a-z0-9 slug silently produced none. Review findings folded in from the regression pass: the non-Latin regression (a-z0-9 slug), the GB formatter asymmetry, and the pull byte count were all found by review, not by the patch authors, and each now has a test. Tests: test-webdav-sizes.js 27 (was RED 15/24 before the fix), test-memory-graph.js 37 (RED 17/32), both auto-discovered by newos4-maintain -- 26/26 node tests. Live on v4.6.140: _parseAutoindexSize('21K') 21504, _formatSize -> "21.0 KB", _formatSize(2GiB) "2.0 GB", slug Cyrillic "исправить-парсер-ошибок", 0 console errors.
Diffstat (limited to 'modules/test/test-webdav-sizes.js')
-rw-r--r--modules/test/test-webdav-sizes.js239
1 files changed, 239 insertions, 0 deletions
diff --git a/modules/test/test-webdav-sizes.js b/modules/test/test-webdav-sizes.js
new file mode 100644
index 0000000..68cb90b
--- /dev/null
+++ b/modules/test/test-webdav-sizes.js
@@ -0,0 +1,239 @@
+// 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=<path> 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 =
+ '<html><body><h1>Index of /</h1><hr><pre>'
+ + '<a href="../">../</a> 02-Nov-2026 09:13 -\n'
+ + '<a href="CHANGELOG.md">CHANGELOG.md</a> 02-Nov-2026 09:14 21K\n'
+ + '<a href="downloads/">downloads/</a> 02-Nov-2026 09:14 -\n'
+ + '<a href="small.txt">small.txt</a> 02-Nov-2026 09:15 512\n'
+ + '<a href="big.bin">big.bin</a> 02-Nov-2026 09:16 1.2M\n'
+ + '<a href="huge.bin">huge.bin</a> 02-Nov-2026 09:17 2G\n'
+ + '<a href="memgraph.html">memgraph.html</a> 02-Nov-2026 09:18 45K\n'
+ + '</pre><hr></body></html>';
+ 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('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);
+});