diff options
| author | Agando <roduccomm@gmail.com> | 2026-11-02 21:05:58 +0100 |
|---|---|---|
| committer | Agando <roduccomm@gmail.com> | 2026-11-02 21:05:58 +0100 |
| commit | 35ca5324ca5a0b3e1e6f02663e1e44d6e06e50ba (patch) | |
| tree | da0bfbc6c429350adb044ec43ac761f9b7eedd2f /modules/test | |
| parent | 5427042d86f8e6a08a684a0315932c306472e7f8 (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')
| -rw-r--r-- | modules/test/test-memory-graph.js | 253 | ||||
| -rw-r--r-- | modules/test/test-webdav-sizes.js | 239 |
2 files changed, 492 insertions, 0 deletions
diff --git a/modules/test/test-memory-graph.js b/modules/test/test-memory-graph.js new file mode 100644 index 0000000..d4fe37a --- /dev/null +++ b/modules/test/test-memory-graph.js @@ -0,0 +1,253 @@ +// node modules/test/test-memory-graph.js +// test-memory-graph: five confirmed defects in module-memory-graph.js. +// +// T1 addEdge() averaged weights, so every repeat of a strong edge pulled it down toward the +// constant its caller passed. A relation that was used once at 0.9 and then observed at +// 0.5 decayed to 0.5 for being used. Evidence must be monotonic: stronger wins. +// T2 recordTask() keyed task nodes on Date.now(), so re-submitting the same task created a +// brand new node every millisecond instead of reinforcing the existing one. +// T3 recordFileAccess() recorded the graph's own store (/.memory_graph/*) as user files, +// so the graph observed itself and grew every time it saved. +// T4 failed tool calls were persisted at weight 0.8 and successes at 0.5, which made +// showStats() rank failures as the strongest relationships in the graph. +// T5 concept ids were built from a whitespace-only split, so ids carried punctuation +// ("concept:parser," ) and never matched an id built from the same word elsewhere. +// +// Set MEMORY_GRAPH_PATH to test a copy of the module (used to prove the test is RED on the +// pre-fix file). prune() is deliberately untouched and T6.5 pins that. +const fs = require('fs'), path = require('path'); +const ROOT = path.join(__dirname, '..', 'core'); +const MG_FILE = process.env.MEMORY_GRAPH_PATH || path.join(ROOT, 'module-memory-graph.js'); + +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.indexedDB = { open() { throw new Error('no idb'); }, deleteDatabase() { throw new Error('no idb'); } }; +global.window = global; + +const load = (file, cls) => new Function(fs.readFileSync(file, 'utf8') + '\nreturn ' + cls + ';')(); + +let VFS, MG; +try { + VFS = load(path.join(ROOT, 'virtual-fs.js'), 'VirtualFileSystem'); + MG = load(MG_FILE, 'MemoryGraphModule'); +} catch (e) { + console.log('FAIL could not load module — ' + e.message); + process.exit(1); +} +if (typeof MG !== 'function') { + console.log('FAIL module-memory-graph.js did not expose MemoryGraphModule (got ' + typeof MG + ')'); + process.exit(1); +} + +let fails = 0, passes = 0; +function ok(name, cond, detail) { + if (cond) { passes++; return; } + fails++; + console.log('FAIL ' + name + (detail ? ' — ' + detail : '')); +} + +function mk() { + const out = []; + const vfs = new VFS(); + vfs.saveToStorage = () => true; + const term = { print: (m, t) => out.push({ text: String(m), type: t || '' }), log: () => {}, fs: vfs }; + const mg = new MG(); + mg.init(term); + return { mg, vfs, out, term }; +} +const text = arr => arr.map(o => o.text).join('\n'); +// Legacy task ids are Date.now()-based: make sure two calls land in different milliseconds so +// the RED run cannot pass by the two calls sharing one tick. +const tick = ms => { const t = Date.now(); while (Date.now() - t < ms) { /* spin */ } }; +const iso = new Date('2024-01-05T10:00:00.000Z').toISOString(); +const sessionOf = d => 'session-session-' + Math.floor(new Date(d).getTime() / 86400000); + +// ─── T1: addEdge must not erode a strong edge ─── +{ + const { mg } = mk(); + mg.addEdge('a', 'b', 'used', 0.9); + mg.addEdge('a', 'b', 'used', 0.5); + mg.addEdge('a', 'b', 'used', 0.5); + const e = mg._edges[0]; + ok('T1.1 a strong edge is not eroded by repeated weak observations', + !!e && e.weight === 0.9, 'weight ' + (e && e.weight)); + ok('T1.2 repeats still count as use', !!e && e.use_count === 3, 'use_count ' + (e && e.use_count)); + ok('T1.3 repeats do not duplicate the edge', mg._edges.length === 1, 'edges ' + mg._edges.length); + + mg.addEdge('a', 'b', 'used', 1); + ok('T1.4 stronger evidence still raises the weight', e.weight === 1, 'weight ' + e.weight); +} + +// ─── T2: task ids are content-derived, so identical tasks merge ─── +{ + const { mg } = mk(); + const TASK = 'Read the parser and fix the lexer bug'; + mg.recordTask(TASK, 'ok', 'agent-a'); + tick(3); + mg.recordTask(TASK, 'ok', 'agent-a'); + const tasks = () => Object.values(mg._nodes).filter(n => n.type === 'task'); + ok('T2.1 identical re-submits collapse to one task node', + tasks().length === 1, 'task nodes: ' + tasks().map(t => t.id).join(',')); + ok('T2.2 the merged task records both uses', + tasks().length === 1 && tasks()[0].access_count === 2, 'access_count ' + (tasks()[0] || {}).access_count); + const ex = mg._edges.filter(e => e.type === 'executed'); + ok('T2.3 one executed edge, reinforced', + ex.length === 1 && ex[0].use_count === 2, 'edges ' + ex.length + ' use_count ' + (ex[0] || {}).use_count); + + tick(3); + mg.recordTask(TASK, 'ok', 'agent-b'); + ok('T2.4 a different agent keeps its own task node', tasks().length === 2, + 'task nodes: ' + tasks().map(t => t.id).join(',')); + + tick(3); + mg.recordTask('Deploy the release notes', 'ok', 'agent-a'); + ok('T2.5 different text gives a different task id', tasks().length === 3, + 'task nodes: ' + tasks().length); + + tick(3); + mg.recordTask(' read the PARSER and fix the lexer bug ', 'ok', 'agent-a'); + ok('T2.6 whitespace and case variants still dedupe', tasks().length === 3, + 'task nodes: ' + tasks().map(t => t.label).join(' | ')); +} + +// ─── T3: the graph must not record its own store ─── +{ + const { mg } = mk(); + const nodesBefore = Object.keys(mg._nodes).length; + const edgesBefore = mg._edges.length; + mg.recordFileAccess('/.memory_graph/nodes.json'); + mg.recordFileAccess('/.memory_graph/edges.json', 'agent-a'); + mg.recordFileAccess('/.memory_graph/'); + mg.recordFileAccess('.memory_graph/export.json'); + + const selfFiles = Object.keys(mg._nodes).filter(k => k.indexOf('.memory_graph') !== -1); + ok('T3.1 store paths are not recorded as file nodes', selfFiles.length === 0, selfFiles.join(',')); + ok('T3.2 no extra nodes from the store', Object.keys(mg._nodes).length === nodesBefore, + 'before ' + nodesBefore + ' after ' + Object.keys(mg._nodes).length); + ok('T3.3 no extra edges from the store', mg._edges.length === edgesBefore, + 'before ' + edgesBefore + ' after ' + mg._edges.length); + + mg.recordFileAccess('/src/app.js'); + ok('T3.4 ordinary paths are still recorded', !!mg.getNode('file', '/src/app.js'), + Object.keys(mg._nodes).join(',')); + ok('T3.5 an ordinary path still gets its accessed edge', + mg._edges.some(e => e.type === 'accessed' && e.target === 'file:/src/app.js')); +} + +// ─── T4: a failure must not outrank a success ─── +{ + const { mg, out } = mk(); + mg.recordToolCall('read_file', true); + mg.recordToolCall('write_file', false); + const used = mg._edges.find(e => e.type === 'used'); + const failed = mg._edges.find(e => e.type === 'failed'); + ok('T4.1 both outcomes are recorded', !!used && !!failed, + mg._edges.map(e => e.type + ':' + e.weight).join(',')); + ok('T4.2 a failed tool call is weaker than a successful one', + !!used && !!failed && failed.weight < used.weight, + 'used ' + (used || {}).weight + ' failed ' + (failed || {}).weight); + ok('T4.3 success keeps its weight', !!used && used.weight === 0.5, String((used || {}).weight)); + + const mark = out.length; + mg.showStats(); + const stats = out.slice(mark).map(o => o.text).join('\n'); + const lines = stats.split('\n'); + const at = lines.findIndex(l => l.indexOf('Strongest relationships') !== -1); + const strong = at === -1 ? [] : lines.slice(at + 1, at + 4).filter(Boolean); + ok('T4.4 showStats still lists strongest relationships', strong.length >= 2, stats.slice(0, 300)); + ok('T4.5 the failure is not ranked above the success', + strong.length >= 2 && strong[0].indexOf('used') !== -1 && strong[1].indexOf('failed') !== -1, + strong.join(' | ')); +} + +// ─── T5: concept ids are slugged ─── +{ + const { mg } = mk(); + mg.recordTask('Refactor: the Parser (v2!), then ship it.', 'done', 'agent-a'); + const concepts = Object.values(mg._nodes).filter(n => n.type === 'concept'); + ok('T5.1 task keywords become concept nodes', concepts.length > 0, 'none'); + ok('T5.2 concept ids carry no punctuation', + concepts.every(c => /^concept:[a-z0-9]+(-[a-z0-9]+)*$/.test(c.id)), + concepts.map(c => c.id).join(',')); + const involves = mg._edges.filter(e => e.type === 'involves'); + ok('T5.3 every involves edge points at a real concept node', + involves.length > 0 && involves.every(e => !!mg._nodes[e.target]), + involves.map(e => e.target).join(',')); + + mg.recordConcept('Neural VFS, v2!', '/src/nvfs.js'); + ok('T5.4 recordConcept slugifies its id', !!mg.getNode('concept', 'neural-vfs-v2'), + Object.keys(mg._nodes).filter(k => k.indexOf('concept:') === 0).join(',')); + ok('T5.5 the concept→file edge uses the slugged id', + mg._edges.some(e => e.type === 'maps_to' && e.source === 'concept:neural-vfs-v2')); +} + +// ─── T6: graphs already stored keep loading, and prune() is untouched ─── +{ + const { vfs } = mk(); + const recent = new Date().toISOString(); + const legacyNodes = { + 'session:session-legacy': { id: 'session:session-legacy', type: 'session', label: 'Legacy session', + metadata: {}, created: iso, updated: iso, access_count: 1, namespace: 'global', source_session: 'session-legacy' }, + 'task:task-1700000000000': { id: 'task:task-1700000000000', type: 'task', label: 'legacy task', + metadata: { full: 'legacy task' }, created: iso, updated: iso, access_count: 1, + namespace: 'global', source_session: 'session-legacy' }, + 'concept:orphan': { id: 'concept:orphan', type: 'concept', label: 'orphan', + metadata: {}, created: iso, updated: iso, access_count: 1, namespace: 'global', source_session: 'session-legacy' } + }; + const legacyEdges = [ + { source: 'session:session-legacy', target: 'task:task-1700000000000', type: 'executed', + weight: 0.6, created: iso, last_used: recent, use_count: 1, source_agents: [] }, + { source: 'session:session-legacy', target: 'tool:write_file', type: 'failed', + weight: 0.8, created: iso, last_used: recent, use_count: 1, source_agents: ['agent-a'] } + ]; + vfs.mkdirRecursive('/.memory_graph'); + vfs.fsWriteJSON('/.memory_graph/nodes.json', legacyNodes); + vfs.fsWriteJSON('/.memory_graph/edges.json', legacyEdges); + + const out2 = []; + const mg2 = new MG(); + mg2.init({ print: (m, t) => out2.push({ text: String(m), type: t || '' }), log: () => {}, fs: vfs }); + + ok('T6.1 a legacy graph still loads', Object.keys(mg2._nodes).length >= 4, + 'nodes ' + Object.keys(mg2._nodes).length); + ok('T6.2 legacy Date.now() task ids are still addressable', + !!mg2.getNode('task', 'task-1700000000000')); + ok('T6.3 legacy edges keep their shape', + mg2._edges.every(e => Array.isArray(e.source_agents) && typeof e.weight === 'number'), + JSON.stringify(mg2._edges)); + const legFailed = mg2._edges.find(e => e.type === 'failed'); + ok('T6.4 a legacy 0.8 failure is capped below success on load', + !!legFailed && legFailed.weight < 0.5, 'weight ' + (legFailed || {}).weight); + ok('T6.5 unrelated legacy weights are untouched', + (mg2._edges.find(e => e.type === 'executed') || {}).weight === 0.6, + String((mg2._edges.find(e => e.type === 'executed') || {}).weight)); + + // prune() is correct and must stay correct: it drops zero-degree nodes and keeps the rest. + mg2.addNode('concept', 'second-orphan', 'second orphan'); + const orphanPresent = !!mg2._nodes['concept:second-orphan']; + mg2.prune(); + ok('T6.6 prune still drops zero-degree nodes (prune untouched)', + orphanPresent && !mg2._nodes['concept:second-orphan'], Object.keys(mg2._nodes).join(',')); + ok('T6.7 prune keeps edges that are still in use', mg2._edges.length === 2, + 'edges ' + mg2._edges.length); +} + +// T7. slugging must not erase non-Latin scripts: an a-z0-9 slug turned a Cyrillic task +// into '' (no concept nodes at all) and German 'Ärger' into 'rger'. +{ + const { mg } = mk(); + ok('T7.1 cyrillic slug is not empty', mg._slug('Исправить парсер ошибок') === 'исправить-парсер-ошибок', mg._slug('Исправить парсер ошибок')); + ok('T7.2 cjk slug is not empty', mg._slug('修复解析器的错误') === '修复解析器的错误', mg._slug('修复解析器的错误')); + ok('T7.3 accents survive', mg._slug('Ärger mit Größe') === 'ärger-mit-größe', mg._slug('Ärger mit Größe')); + ok('T7.4 ascii is unchanged', mg._slug('Neural VFS, v2!') === 'neural-vfs-v2', mg._slug('Neural VFS, v2!')); + mg.recordTask('Исправить парсер ошибок', 'done', 'agent-ru'); + const cyr = Object.keys(mg._nodes).filter(k => k.indexOf('concept:') === 0); + ok('T7.5 a cyrillic task still yields concept nodes', cyr.length > 0, JSON.stringify(cyr.slice(0, 3))); +} + +console.log('test-memory-graph: ' + passes + ' passed, ' + fails + ' failed'); +process.exit(fails ? 1 : 0); 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); +}); |