Initial commit: LinkSyncServer and LinkSyncExtension projects with complete documentation, models, API endpoints, tests, and extension implementation

This commit is contained in:
DavidSaylor
2026-05-11 17:37:10 -05:00
parent ad0b12b452
commit aed69afdfd
691 changed files with 181874 additions and 28 deletions

View File

@@ -0,0 +1,245 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<title>LinkdingSync Automated Tests</title>
<style>
body { font-family: monospace; padding: 20px; background: #1e1e1e; color: #d4d4d4; }
h1 { color: #4ec9b0; }
.table { margin: 10px 0; }
.input { padding: 5px; background: #2d2d2d; border: 1px solid #3d3d3d; color: #d4d4d4; }
.btn { padding: 8px 16px; margin: 5px 5px 5px 0; background: #0e639c; border: 1px solid #1971c2; color: #fff; cursor: pointer; }
.btn:hover { background: #1177bb; }
.btn:disabled { background: #3d3d3d; border-color: #555; cursor: not-allowed; }
.log { background: #0d0d0d; padding: 10px; border-radius: 4px; min-height: 300px; max-height: 600px; overflow-y: auto; white-space: pre-wrap; font-size: 12px; margin: 10px 0; border: 1px solid #333; }
.pass { color: #50fa7b; }
.fail { color: #ff5555; }
.warn { color: #f1fa8c; }
.info { color: #8be9fd; }
.progress { height: 20px; background: #2d2d2d; border-radius: 3px; margin: 10px 0; overflow: hidden; }
.progress-bar { height: 100%; background: #50fa7b; width: 0%; transition: width 0.3s; }
.status { font-weight: bold; padding: 5px; border-radius: 3px; margin: 5px 0; }
.status.running { background: #2aa198; }
.status.done { background: #0e639c; }
.status.error { background: #ff5555; }
</style>
</head>
<body>
<h1>🔗 LinkdingSync Automated Tests</h1>
<div class="table">
<label>Server URL:</label> <input type="text" id="server" value="https://links.blabber1565.com" class="input">
</div>
<div class="table">
<label>Work API Key:</label> <input type="password" id="wkey" value="4108e3aff26fb82bf074f5d4dfa4757763520b06" class="input">
</div>
<div class="table">
<label>Personal API Key:</label> <input type="password" id="pkey" value="9b80accd3b9b4b91c2a7adc3dcf41621b025329a" class="input">
</div>
<div style="margin: 10px 0;">
<button id="run" class="btn">▶ Run All Tests</button>
<button id="cleanup" class="btn">🧹 Cleanup</button>
<button id="list" class="btn">📋 List Bookmarks</button>
<button id="reset" class="btn">🔄 Reset & Run</button>
</div>
<div class="progress">
<div id="progress-bar" class="progress-bar"></div>
</div>
<div id="status" class="status"></div>
<div id="log" class="log"></div>
<script>
(function() {
'use strict';
// CONFIG
var server = document.getElementById('server').value;
var wkey = document.getElementById('wkey').value;
var pkey = document.getElementById('pkey').value;
// UI
var log = document.getElementById('log');
var status = document.getElementById('status');
var bar = document.getElementById('progress-bar');
// Log messages
function logMsg(msg, cls) {
var div = document.createElement('div');
div.textContent = msg;
div.className = cls || '';
log.insertBefore(div, log.firstChild);
}
function updateProgress(percent) {
bar.style.width = percent + '%';
}
function setStatus(msg, cls) {
status.textContent = msg;
status.className = 'status ' + cls;
}
// API wrapper
function api(method, endpoint, data) {
var url = server + endpoint;
return fetch(url, {
method: method,
headers: { Authorization: 'Token ' + wkey, 'Content-Type': 'application/json' },
body: data ? JSON.stringify(data) : null
}).then(function(res) {
if (res.ok) return res.json();
if (res.status === 404) return { error: '404', status: res.status };
throw new Error(res.status + ': ' + res.statusText);
});
}
// TESTS
var results = [];
var testNum = 0;
var totalTests = 6;
function runTest(name, fn) {
return fn().then(function(r) {
var cls = r.pass === true ? 'pass' : (r.pass === false ? 'fail' : 'warn');
results.push(r);
logMsg('TEST ' + (testNum++ + 1) + ': ' + name + ' ' + (r.pass ? (cls === 'pass' ? '✓' : '⚠') : '✗') + ' ' + r.reason, cls);
return r;
}).catch(function(e) {
logMsg('TEST ' + (testNum++ + 1) + ': ' + name + ' ERROR: ' + e.message, 'fail');
results.push({ pass: false, reason: 'Error: ' + e.message });
return { pass: false, reason: 'Error: ' + e.message };
});
}
// RUN ALL TESTS
function runAll() {
log.innerHTML = '';
updateProgress(0);
logMsg('Starting tests...', 'info');
runTest('API Key Isolation', function() {
return api('POST', '/api/bookmarks/', { url: 'https://t1.example.com', title: 'W1', notes: JSON.stringify({ testId: true }) })
.then(function(b1) { return api('POST', '/api/bookmarks/', { url: 'https://t1.example.com', title: 'P1', notes: JSON.stringify({ testId: true }) }); })
.then(function(b2) {
return { pass: b1.id !== b2.id, reason: b1.id === b2.id ? 'API keys do NOT provide isolation' : 'API keys provide isolation' };
});
}).then(function() {
updateProgress(16.7);
return runTest('Cross-User Visibility', function() {
return api('GET', '/api/bookmarks/?limit=100').then(function(d) {
var myTests = (d.results || []).filter(function(b) { return b.testId || b.notes?.testId; });
return { pass: myTests.length <= 1, reason: myTests.length === 1 ? 'User isolation works' : 'Sees ' + myTests.length + ' test bookmarks' };
});
});
}).then(function() {
updateProgress(33.3);
return runTest('Conflict Resolution', function() {
return api('POST', '/api/bookmarks/', { url: 'https://t3.example.com', title: 'W', path: 'W', notes: JSON.stringify({ testId: true }) })
.then(function(b1) { return api('POST', '/api/bookmarks/', { url: 'https://t3.example.com', title: 'P', path: 'P', notes: JSON.stringify({ testId: true }) }); })
.then(function(b2) {
return { pass: b1.id !== b2.id, reason: b1.id === b2.id ? 'Server merges by URL' : 'Server creates separate bookmarks' };
});
});
}).then(function() {
updateProgress(50);
return runTest('Field Update Behavior', function() {
return api('POST', '/api/bookmarks/', { url: 'https://t4.example.com', title: 'Initial', notes: JSON.stringify({ testId: true }) })
.then(function(bm) {
return api('PUT', '/api/bookmarks/' + bm.id + '/', { title: 'Work Title', notes: JSON.stringify({ testId: true }) });
})
.then(function() {
return api('GET', '/api/bookmarks/' + bm.id + '/');
})
.then(function(f) {
return { pass: true, reason: 'Field updates work' };
});
});
}).then(function() {
updateProgress(66.7);
return runTest('Delete Behavior', function() {
return api('POST', '/api/bookmarks/', { url: 'https://t5.example.com', title: 'W', notes: JSON.stringify({ testId: true }) })
.then(function(b1) { return api('POST', '/api/bookmarks/', { url: 'https://t5.example.com', title: 'P', notes: JSON.stringify({ testId: true }) }); })
.then(function(b2) { return api('DELETE', '/api/bookmarks/' + b1.id + '/'); })
.then(function() { return api('GET', '/api/bookmarks/?limit=100&url=https://t5.example.com'); })
.then(function(d) {
return { pass: (d.results || []).length === 1, reason: (d.results || []).length === 1 ? 'Delete isolated' : 'Delete propagated' };
});
});
}).then(function() {
updateProgress(83.3);
return runTest('Bundle Filtering', function() {
return Promise.all([
api('POST', '/api/bookmarks/', { url: 'https://b6-1.example.com', title: 'B1', notes: JSON.stringify({ testId: true }) }),
api('POST', '/api/bookmarks/', { url: 'https://b6-2.example.com', title: 'B2', notes: JSON.stringify({ testId: true }) })
]).then(function() {
return api('GET', '/api/bookmarks/?all=work&limit=100').then(function(wd) {
return api('GET', '/api/bookmarks/?all=personal&limit=100').then(function(pd) {
return { pass: true, reason: 'Bundle filtering works', work: wd.count, personal: pd.count };
});
});
});
});
}).then(function() {
updateProgress(100);
return new Promise(function(resolve) {
var summary = '';
var passed = results.filter(function(r) { return r.pass === true; }).length;
var failed = results.filter(function(r) { return r.pass === false; }).length;
var warned = results.filter(function(r) { return r.pass === null; }).length;
summary += 'Total: ' + results.length + '\n';
summary += 'Passed: ' + passed + '\n';
summary += 'Failed: ' + failed + '\n';
summary += 'Warning: ' + warned;
logMsg(summary, 'info');
resolve();
});
}).then(function() {
setStatus('Tests complete', 'done');
logMsg('Results available in log above', 'info');
}).catch(function(e) {
logMsg('Error: ' + e.message, 'error');
});
}
// BUTTON HANDLERS
document.getElementById('run').addEventListener('click', runAll);
document.getElementById('reset').addEventListener('click', function() {
log.innerHTML = '';
runAll();
});
document.getElementById('cleanup').addEventListener('click', function() {
api('GET', '/api/bookmarks/?limit=100').then(function(d) {
var tests = (d.results || []).filter(function(b) { return b.testId || b.notes?.testId; });
logMsg('Cleaning up ' + tests.length + ' test bookmarks...', 'info');
if (tests.length) {
Promise.all(tests.map(function(t) { return api('DELETE', '/api/bookmarks/' + t.id + '/'); })).then(function() {
logMsg('Cleanup complete', 'info');
});
}
});
});
document.getElementById('list').addEventListener('click', function() {
api('GET', '/api/bookmarks/?limit=100').then(function(d) {
logMsg('All bookmarks: ' + (d.count || 0), 'info');
if (d.results) {
d.results.forEach(function(b) { logMsg(' ' + b.url + ' [' + b.title + ']'); });
}
});
});
// AUTO-RUN ON LOAD
window.addEventListener('load', function() {
logMsg('Test runner loaded. Click "Run All Tests" to start.', 'info');
logMsg('Default credentials will be used if you click Run without changing them.', 'info');
});
})();
</script>
</body>
</html>

View File

@@ -0,0 +1,257 @@
/*
* LinkdingSync Console Test Runner
* Paste directly into Firefox DevTools Console
*
* IMPORTANT: Firefox console adds a wrapper, so we assign to window directly
*/
'use strict';
(function(w) {
'use strict';
var window = w || window;
// CONFIG
var serverUrl = 'https://links.blabber1565.com';
var workApiKey = '4108e3aff26fb82bf074f5d4dfa4757763520b06';
var personalApiKey = '9b80accd3b9b4b91c2a7adc3dcf41621b025329a';
var workUser = 'linkdingsync_tester';
var personalUser = 'linkdingsync_tester_2';
// STATE
var state = { url: '', apiKey: '', userId: null };
// SET CONTEXT
function setContext(key, url, apiKey, userId) {
state.url = url.endsWith('/') ? url : url + '/';
state.apiKey = apiKey;
state.userId = userId;
}
// API CALL
function call(method, endpoint, data) {
var u = new URL(endpoint, state.url);
var r = fetch(u, {
method: method,
headers: {
'Authorization': 'Token ' + state.apiKey,
'Content-Type': 'application/json'
},
body: data ? JSON.stringify(data) : null
});
return r.then(function(res) {
if (!res.ok) throw new Error(res.status + ': ' + res.statusText);
return res.json();
});
}
// CREATE BOOKMARK
function create(url, opts) {
var testId = 'test-' + Date.now().toString().slice(-4) + '-' + Math.random().toString(36).slice(2,4);
var base = new URL(url);
base.hostname = testId + '.' + base.hostname;
var data = {
url: base.href,
title: opts.title || 'Test: ' + testId,
description: 'Test',
notes: JSON.stringify({ testId, path: 'Test/' + testId })
};
console.log(' Created: ID=' + data.url);
return call('POST', '/api/bookmarks/', data);
}
// DELETE
function del(id) {
return call('DELETE', '/api/bookmarks/' + id + '/');
}
// LIST
function list(q) {
return call('GET', '/api/bookmarks/' + (q || '?limit=100'));
}
// RESET
async function reset() {
console.log('[Reset] Clearing test bookmarks...');
var all = await call('GET', '/api/bookmarks/?limit=100');
var tests = all.results.filter(function(b) { return b.testId; });
if (tests.length) {
console.log('[Reset] Found ' + tests.length + ' to delete');
for (var i = 0; i < tests.length; i++) {
await del(tests[i].id);
}
console.log('[Reset] Done');
}
}
// TEST 1
(function test1() {
console.log('\n=== Test 1: API Key Isolation ===');
setContext('work', serverUrl, workApiKey, workUser);
create('https://t1.example.com', { title: 'T1-Work' }).then(function(b1) {
console.log(' Work ID: ' + b1.id);
setContext('personal', serverUrl, personalApiKey, personalUser);
return create('https://t1.example.com', { title: 'T1-Personal' });
}).then(function(b2) {
console.log(' Personal ID: ' + b2.id);
console.log(' Same? ' + (b1.id === b2.id));
if (b1.id === b2.id) {
console.log(' [Test 1] ✗ FAIL - API keys do NOT provide isolation');
} else {
console.log(' [Test 1] ✓ PASS - API keys provide isolation');
}
});
})();
// TEST 2
(function test2() {
console.log('\n=== Test 2: Cross-User Isolation ===');
setContext('work', serverUrl, workApiKey, workUser);
var bm = create('https://t2.example.com', { title: 'T2-Work' });
bm.then(function(bm) {
console.log(' Work ID: ' + bm.id);
setContext('personal', serverUrl, personalApiKey, personalUser);
return call('GET', '/api/bookmarks/?limit=100');
}).then(function(d) {
console.log(' Personal sees: ' + d.count + ' bookmarks');
if (d.results && d.results.length) {
console.log(' [Test 2] ✗ FAIL - Users see each other');
} else {
console.log(' [Test 2] ✓ PASS - Proper isolation');
}
});
})();
// TEST 3
(function test3() {
console.log('\n=== Test 3: Conflict Resolution ===');
setContext('work', serverUrl, workApiKey, workUser);
var u = 'https://t3.example.com';
var b1 = create(u, { title: 'T3-Work', path: 'Work' });
b1.then(function(b1) {
console.log(' Work ID: ' + b1.id);
setContext('personal', serverUrl, personalApiKey, personalUser);
var b2 = create(u, { title: 'T3-Personal', path: 'Personal' });
return b2;
}).then(function(b2) {
console.log(' Personal ID: ' + b2.id);
console.log(' Same? ' + (b1.id === b2.id));
if (b1.id === b2.id) {
console.log(' [Test 3] ✗ FAIL - Server merges by URL');
} else {
console.log(' [Test 3] ✓ PASS - Server creates separate');
}
});
})();
// TEST 4
(function test4() {
console.log('\n=== Test 4: Last-Write-Wins ===');
setContext('work', serverUrl, workApiKey, workUser);
var u = 'https://t4.example.com';
create(u, { title: 'Initial', path: 'Init' }).then(function(bm) {
console.log(' Initial ID: ' + bm.id);
return call('PUT', '/api/bookmarks/' + bm.id + '/', {
title: 'Work Title',
description: 'Work Desc',
notes: JSON.stringify({ path: 'Work/Dev', userNotes: 'Work' })
});
}).then(function() {
console.log(' Updated: Work Title');
setContext('personal', serverUrl, personalApiKey, personalUser);
return call('PUT', '/api/bookmarks/' + bm.id + '/', {
title: 'Personal Title',
description: 'Personal Desc',
notes: JSON.stringify({ path: 'Personal/Notes', userNotes: 'Personal' })
});
}).then(function() {
console.log(' Updated: Personal Title');
setContext('work', serverUrl, workApiKey, workUser);
return call('GET', '/api/bookmarks/' + bm.id + '/');
}).then(function(f) {
console.log('\n Final:');
console.log(' Title: ' + f.title);
console.log(' Path: ' + JSON.parse(f.notes).path);
if (f.title === 'Personal Title') {
console.log(' [Test 4] ✓ PASS - Last-write-wins (Personal)');
} else {
console.log(' [Test 4] ✓ PASS - Last-write-wins (Work)');
}
});
})();
// TEST 5
(function test5() {
console.log('\n=== Test 5: Delete Propagation ===');
setContext('work', serverUrl, workApiKey, workUser);
var u = 'https://t5.example.com';
var b1 = create(u, { title: 'T5-Work', path: 'Work' });
b1.then(function(b1) {
setContext('personal', serverUrl, personalApiKey, personalUser);
var b2 = create(u, { title: 'T5-Personal', path: 'Personal' });
return b2;
}).then(function(b2) {
console.log(' Work ID: ' + b1.id);
console.log(' Personal ID: ' + b2.id);
console.log(' Same? ' + (b1.id === b2.id));
setContext('work', serverUrl, workApiKey, workUser);
return call('DELETE', '/api/bookmarks/' + b1.id + '/');
}).then(function() {
console.log(' Deleted via Work');
setContext('personal', serverUrl, personalApiKey, personalUser);
return call('GET', '/api/bookmarks/?limit=100&url=' + u);
}).then(function(d) {
if (d.count === 0) {
console.log(' [Test 5] ✗ FAIL - Delete propagated');
} else {
console.log(' [Test 5] ✓ PASS - Delete isolated');
}
});
})();
// TEST 6
(function test6() {
console.log('\n=== Test 6: Bundle Filtering ===');
setContext('work', serverUrl, workApiKey, workUser);
var u1 = 'https://b6-1.example.com';
var u2 = 'https://b6-2.example.com';
create(u1, { title: 'B6-W1' }).then(function() {
return create(u2, { title: 'B6-W2' });
}).then(function() {
console.log(' Created 2 bookmarks');
setContext('work', serverUrl, workApiKey, workUser);
return call('GET', '/api/bookmarks/?all=work&limit=100');
}).then(function(d) {
console.log(' Work bundle: ' + (d.count || d.results?.length || 0) + ' bookmarks');
setContext('personal', serverUrl, personalApiKey, personalUser);
return call('GET', '/api/bookmarks/?all=personal&limit=100');
}).then(function(d) {
console.log(' Personal bundle: ' + (d.count || d.results?.length || 0) + ' bookmarks');
console.log(' [Test 6] ✓ PASS - Bundle filtering works');
});
})();
// SUMMARY
(function summary() {
console.log('\n' + '='.repeat(60));
console.log(' Test Suite Complete');
console.log('='.repeat(60));
})();
// RESET FUNCTION
window.LinkdingSyncTests = {
reset: reset,
call: call,
create: create,
del: del,
list: list,
setContext: setContext
};
})(window);
console.log('');
console.log('LinkdingSync Console Test Runner loaded');
console.log('');
console.log('Tests are running automatically...');
console.log('Use LinkdingSyncTests.reset() to clean up test bookmarks');

View File

@@ -0,0 +1,194 @@
/*
* LinkdingSync Final Test Runner
* Firefox Console Compatible
*/
(function(w, eval) {
'use strict';
var window = w || window;
var E = eval;
// === CONFIG ===
var SERVER_URL = 'https://links.blabber1565.com';
var WORK_KEY = '4108e3aff26fb82bf074f5d4dfa4757763520b06';
var PERSONAL_KEY = '9b80accd3b9b4b91c2a7adc3dcf41621b025329a';
var WORK_USER = 'linkdingsync_tester';
var PERSONAL_USER = 'linkdingsync_tester_2';
// === HELPERS ===
var STATE = { URL: '', KEY: '', USER: null };
// Safe URL parsing - handles console-modified strings
function parseUrl(str) {
try {
return new URL(str);
} catch(e) {
console.log(' [WARN] Invalid URL: ' + str);
return null;
}
}
function API(method, endpoint, data) {
var url = parseUrl(STATE.URL + endpoint);
if (!url) throw new Error('Invalid base URL');
var r = E(function(res) {
if (!res.ok && res.status === 404) return { error: '404', status: res.status };
if (!res.ok) throw new Error(res.status + ': ' + res.statusText);
return res.json();
})(url, { method: method, headers: { 'Authorization': 'Token ' + STATE.KEY, 'Content-Type': 'application/json' }, body: data ? JSON.stringify(data) : null });
return r;
}
// === TESTS ===
var RESULTS = [];
function TEST(name, fn) {
console.log(''); console.log('=== ' + name + ' ===');
var promise = E(function() { return fn(); });
promise.then(function(r) {
console.log(' [PASS/FAIL/' + (r.pass === null ? 'WARN' : 'PASS') + '] ' + r.reason);
RESULTS.push(r);
return r;
}).catch(function(e) {
console.log(' [ERROR] ' + e.message);
RESULTS.push({ pass: false, reason: 'Error: ' + e.message });
return { pass: false, reason: 'Error: ' + e.message };
});
return promise;
}
// === MAIN ===
TEST('API Key Isolation', function() {
STATE.URL = SERVER_URL; STATE.KEY = WORK_KEY; STATE.USER = WORK_USER;
return API('POST', '/api/bookmarks/', { url: 'https://t1.example.com', title: 'T1-Work', description: 'Test', notes: JSON.stringify({ testId: true }) })
.then(function(b1) {
STATE.KEY = PERSONAL_KEY; STATE.USER = PERSONAL_USER;
return API('POST', '/api/bookmarks/', { url: 'https://t1.example.com', title: 'T1-Personal', description: 'Test', notes: JSON.stringify({ testId: true }) });
})
.then(function(b2) {
console.log(' IDs: ' + b1.id + ' vs ' + b2.id + ' Same? ' + (b1.id === b2.id));
if (b1.id === b2.id) return { pass: false, reason: 'API keys do NOT provide isolation' };
return { pass: true, reason: 'API keys provide isolation' };
});
}).then(function(r1) {
console.log(''); console.log('=== Cross-User Visibility ===');
STATE.KEY = PERSONAL_KEY; STATE.USER = PERSONAL_USER;
return API('GET', '/api/bookmarks/?limit=100').then(function(data) {
var myTests = data.results ? data.results.filter(function(b) { return b.testId; }) : [];
console.log(' Personal sees ' + (data.count || data.results.length) + ' bookmarks');
console.log(' My test bookmarks: ' + myTests.length);
if (myTests.length === 1 && myTests[0].url === 'https://t1.example.com') {
console.log(' [PASS] Personal only sees my test bookmark');
return { pass: true, reason: 'Proper user isolation' };
}
console.log(' [WARN] Personal sees ' + myTests.length + ' test bookmarks');
return { pass: null, reason: 'Mixed results - check if sharing enabled' };
});
}).then(function(r2) {
console.log(''); console.log('=== Conflict Resolution ===');
STATE.KEY = WORK_KEY; STATE.USER = WORK_USER;
return API('POST', '/api/bookmarks/', { url: 'https://t3.example.com', title: 'T3-Work', description: 'Test', notes: JSON.stringify({ testId: true, path: 'Work' }) })
.then(function(b1) {
STATE.KEY = PERSONAL_KEY; STATE.USER = PERSONAL_USER;
return API('POST', '/api/bookmarks/', { url: 'https://t3.example.com', title: 'T3-Personal', description: 'Test', notes: JSON.stringify({ testId: true, path: 'Personal' }) });
})
.then(function(b2) {
console.log(' IDs: ' + b1.id + ' vs ' + b2.id + ' Same? ' + (b1.id === b2.id));
if (b1.id === b2.id) return { pass: false, reason: 'Server merges by URL' };
return { pass: true, reason: 'Server creates separate bookmarks' };
});
}).then(function(r3) {
console.log(''); console.log('=== Field Update Behavior ===');
STATE.KEY = WORK_KEY; STATE.USER = WORK_USER;
return API('GET', '/api/bookmarks/?limit=1').then(function(data) {
var bm = data.results ? data.results[0] : null;
if (!bm) return API('POST', '/api/bookmarks/', { url: 'https://t4.example.com', title: 'Initial', description: 'Test', notes: JSON.stringify({ testId: true }) });
return API('PUT', '/api/bookmarks/' + bm.id + '/', { title: 'Work Title', description: 'Work', notes: JSON.stringify({ testId: true, path: 'Work' }) });
}).then(function(resp) {
console.log(' Update response: ' + (resp.error ? resp.error : 'OK'));
return API('GET', '/api/bookmarks/' + (resp.url || (resp.id ? '/api/bookmarks/' + resp.id + '/' : '')));
}).catch(function() {
return API('POST', '/api/bookmarks/', { url: 'https://t4-new.example.com', title: 'Initial', description: 'Test', notes: JSON.stringify({ testId: true }) });
}).then(function(f) {
console.log(' Final title: ' + f.title);
if (f.title === 'Work Title') return { pass: true, reason: 'Title was updated' };
if (f.title === 'Initial') return { pass: true, reason: 'Title NOT updated (notes only)' };
return { pass: null, reason: 'Unknown title: ' + f.title };
});
}).then(function(r4) {
console.log(''); console.log('=== Delete Behavior ===');
STATE.KEY = WORK_KEY; STATE.USER = WORK_USER;
return API('POST', '/api/bookmarks/', { url: 'https://t5.example.com', title: 'T5-Work', description: 'Test', notes: JSON.stringify({ testId: true }) })
.then(function(b1) {
STATE.KEY = PERSONAL_KEY; STATE.USER = PERSONAL_USER;
return API('POST', '/api/bookmarks/', { url: 'https://t5.example.com', title: 'T5-Personal', description: 'Test', notes: JSON.stringify({ testId: true }) });
})
.then(function(b2) {
console.log(' IDs: ' + b1.id + ' vs ' + b2.id + ' Same? ' + (b1.id === b2.id));
STATE.KEY = WORK_KEY; STATE.USER = WORK_USER;
return API('DELETE', '/api/bookmarks/' + b1.id + '/');
})
.then(function() {
STATE.KEY = PERSONAL_KEY; STATE.USER = PERSONAL_USER;
return API('GET', '/api/bookmarks/?limit=100&url=https://t5.example.com');
})
.then(function(data) {
var count = data.count || data.results ? data.results.length : 0;
console.log(' Personal sees ' + count + ' with that URL');
if (count === 0) return { pass: false, reason: 'Delete propagated' };
return { pass: true, reason: 'Delete isolated' };
});
}).then(function(r5) {
console.log(''); console.log('=== Bundle Filtering ===');
STATE.KEY = WORK_KEY; STATE.USER = WORK_USER;
return API('POST', '/api/bookmarks/', { url: 'https://b6-1.example.com', title: 'B6-W1', description: 'Test', notes: JSON.stringify({ testId: true }) })
.then(function() {
return API('POST', '/api/bookmarks/', { url: 'https://b6-2.example.com', title: 'B6-W2', description: 'Test', notes: JSON.stringify({ testId: true }) });
})
.then(function() {
console.log(' Created 2 work bookmarks');
STATE.KEY = WORK_KEY; STATE.USER = WORK_USER;
return API('GET', '/api/bookmarks/?all=work&limit=100').then(function(wd) {
var wc = wd.count || wd.results ? wd.results.length : 0;
console.log(' Work bundle: ' + wc + ' bookmarks');
STATE.KEY = PERSONAL_KEY; STATE.USER = PERSONAL_USER;
return API('GET', '/api/bookmarks/?all=personal&limit=100').then(function(pd) {
var pc = pd.count || pd.results ? pd.results.length : 0;
console.log(' Personal bundle: ' + pc + ' bookmarks');
console.log(' [PASS] Bundle filtering works');
return { pass: true, reason: 'Bundle filtering works', work: wc, personal: pc };
});
});
});
}).then(function(r6) {
// SUMMARY
console.log(''); console.log('='.repeat(60)); console.log(' Summary'); console.log('='.repeat(60));
var passed = RESULTS.filter(function(r) { return r.pass === true; }).length;
var failed = RESULTS.filter(function(r) { return r.pass === false; }).length;
var warned = RESULTS.filter(function(r) { return r.pass === null; }).length;
console.log(' Total: ' + RESULTS.length); console.log(' Passed: ' + passed); console.log(' Failed: ' + failed); console.log(' Warning: ' + warned);
console.log('='.repeat(60));
console.log(''); console.log('LinkdingSyncTests available:'); console.log(' cleanup()'); console.log(' listAll()'); console.log(''); console.log('Done!');
return RESULTS;
}).catch(function(e) {
console.log(''); console.log('Error:', e.message); console.log(''); console.log('Use LinkdingSyncTests.cleanup()');
return RESULTS;
});
// === CLEANUP ===
(function cleanup() {
API('GET', '/api/bookmarks/?limit=100').then(function(data) {
var tests = data.results ? data.results.filter(function(b) { return b.testId; }) : [];
console.log(''); console.log('[Cleanup] ' + tests.length + ' test bookmarks');
if (tests.length) {
Promise.all(tests.map(function(t) { return API('DELETE', '/api/bookmarks/' + t.id + '/'); })).then(function() { console.log('[Cleanup] Done'); });
}
});
})();
// === EXPORT ===
window.LinkdingSyncTests = { cleanup: (function() { API('GET', '/api/bookmarks/?limit=100').then(function(data) { var t = data.results ? data.results.filter(function(b) { return b.testId; }) : []; console.log('[Cleanup] ' + t.length + ' bookmarks'); if (t.length) Promise.all(t.map(function(tt) { return API('DELETE', '/api/bookmarks/' + tt.id + '/'); })).then(function() { console.log('[Cleanup] Done'); }); })(); }()) };
})(window, window.eval);
console.log(''); console.log('LinkdingSync Final Test Runner loaded'); console.log(''); console.log('Running tests automatically...');

View File

@@ -0,0 +1,796 @@
/*
* LinkdingSync Test Orchestrator (Inline Version)
* Self-contained - paste entire file directly into Firefox DevTools Console
*
* Instructions:
* 1. Open Firefox DevTools → Console tab
* 2. Copy ENTIRE file contents
* 3. Paste into console (Ctrl+Shift+V)
* 4. Wait for "LinkdingSync Test Suite loaded"
* 5. Run: runAllTestsWithReset()
*/
'use strict';
(function() {
// ====================================================================
// CONFIGURATION
// ====================================================================
const CONFIG = {
serverUrl: 'https://links.blabber1565.com',
workApiKey: '4108e3aff26fb82bf074f5d4dfa4757763520b06',
workUser: 'linkdingsync_tester',
workBundle: 'work',
personalApiKey: '9b80accd3b9b4b91c2a7adc3dcf41621b025329a',
personalUser: 'linkdingsync_tester_2',
personalBundle: 'personal',
cleanupAfterTests: true
};
// ====================================================================
// SESSION MANAGEMENT
// ====================================================================
const SessionManager = {
currentContext: null,
setContext(serverUrl, apiKey, userId, bundle) {
this.currentContext = {
serverUrl: serverUrl.endsWith('/') ? serverUrl : serverUrl + '/',
apiKey, userId, bundle
};
return this;
},
getHeaders() {
if (!this.currentContext) {
throw new Error('No context set. Call setContext() first.');
}
return {
'Authorization': `Token ${this.currentContext.apiKey}`,
'Content-Type': 'application/json'
};
},
async call(endpoint, method = 'GET', queryParams = {}) {
const url = new URL(endpoint, this.currentContext.serverUrl);
Object.entries(queryParams).forEach(([key, value]) => {
url.searchParams.append(key, value);
});
const response = await fetch(url, {
method,
headers: this.getHeaders(),
body: null
});
if (!response.ok) {
const text = await response.text().slice(0, 200);
throw new Error(`${response.status}: ${response.statusText} - ${text}`);
}
return await response.json();
}
};
// ====================================================================
// HELPERS
// ====================================================================
const Helpers = {
generateTestId(prefix = 'test') {
return `${prefix}-${Date.now().toString().slice(-4)}-${Math.random().toString(36).substring(2, 4)}`;
},
async createBookmark(url, options = {}) {
const testId = this.generateTestId();
const baseUrl = new URL(url);
baseUrl.hostname = `${testId}.${baseUrl.hostname}`;
const bookmarkData = {
url: baseUrl.href,
title: options.title || `Test: ${testId}`,
description: options.description || 'Test bookmark',
notes: JSON.stringify({
path: options.path || `Test/${testId}`,
userNotes: options.notes || 'Test bookmark',
testId
})
};
const response = await SessionManager.call('/api/bookmarks/', 'POST', null, {});
console.log(` Created: ID=${response.id}`);
return response;
},
async updateBookmark(bookmarkId, data) {
const response = await SessionManager.call(`/api/bookmarks/${bookmarkId}/`, 'PUT', null, {});
console.log(` Updated: ID=${bookmarkId}`);
return response;
},
async deleteBookmark(bookmarkId) {
await SessionManager.call(`/api/bookmarks/${bookmarkId}/`, 'DELETE', {});
console.log(` Deleted: ID=${bookmarkId}`);
return true;
},
async fetchBookmark(id) {
return SessionManager.call(`/api/bookmarks/${id}/`);
},
parseNotes(noteString) {
if (!noteString) return null;
try {
const parsed = JSON.parse(noteString);
return parsed;
} catch {
return { userNotes: noteString, version: '1.0', path: '', autoTags: [], bundleTag: null };
}
},
async getAllBookmarks() {
let bookmarks = [];
let offset = 0;
const batchSize = 100;
do {
const response = await SessionManager.call('/api/bookmarks/', 'GET', { limit: batchSize, offset });
bookmarks.push(...(response.results || []));
offset += batchSize;
} while (bookmarks.length > offset);
return bookmarks;
},
async resetBookmarks() {
console.log('[Utils] Resetting all bookmarks...');
try {
const allBookmarks = await this.getAllBookmarks();
const testBookmarks = allBookmarks.filter(b => b.testId);
if (testBookmarks.length > 0) {
console.log(`[Utils] Found ${testBookmarks.length} test bookmarks to delete`);
for (const bm of testBookmarks) {
await this.deleteBookmark(bm.id);
}
console.log('[Utils] Reset complete');
} else {
console.log('[Utils] No test bookmarks found');
}
} catch (error) {
console.error('[Utils] Reset failed:', error.message);
throw error;
}
}
};
// ====================================================================
// FORMATTERS
// ====================================================================
const Formatters = {
formatTimestamp(timestamp) {
if (!timestamp) return 'Never';
return new Date(timestamp).toLocaleString('en-US', {
year: 'numeric', month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit'
});
},
consoleHeader(text) {
console.log(''.padEnd(60, '='));
console.log(` ${text}`.padEnd(60, '='.charCodeAt(0) === text.charCodeAt(0) ? '=' : '-').padEnd(60, '='));
console.log(''.padEnd(60, '='));
},
consoleResult(scenario, status, details = '') {
const icon = status === 'PASS' ? '✓' : status === 'FAIL' ? '✗' : '⚠';
const emoji = status === 'PASS' ? '✅' : status === 'FAIL' ? '❌' : '⚠️';
console.log(` [${scenario}] ${icon} ${emoji} ${status}`);
if (details) console.log(` ${details}`);
}
};
// ====================================================================
// TEST MODULE: ISOLATION
// ====================================================================
async function test1_SameUserDifferentKeys() {
console.log('\n=== Test 1: Same URL, Different API Keys, Same User ===');
console.log('Purpose: Verify if API keys provide isolation within same user');
try {
SessionManager.setContext(CONFIG.serverUrl, CONFIG.workApiKey, CONFIG.workUser, CONFIG.workBundle);
const bm1 = await Helpers.createBookmark('https://isolation-test.example.com', {
title: 'Isolation Test - Work Key'
});
SessionManager.setContext(CONFIG.serverUrl, CONFIG.personalApiKey, CONFIG.personalUser, CONFIG.personalBundle);
const bm2 = await Helpers.createBookmark('https://isolation-test.example.com', {
title: 'Isolation Test - Personal Key'
});
console.log(` Work bookmark ID: ${bm1.id}`);
console.log(` Personal bookmark ID: ${bm2.id}`);
if (bm1.id === bm2.id) {
Formatters.consoleResult('Test 1', 'FAIL', 'Same bookmark ID - API keys do NOT provide isolation');
console.log(' → Same user means same bookmarks regardless of API key');
return { pass: false, reason: 'API keys do not provide isolation within same user' };
} else {
Formatters.consoleResult('Test 1', 'PASS', 'Different bookmark IDs - API keys provide isolation');
console.log(' → Different API keys create separate bookmarks');
return { pass: true, ids: { work: bm1.id, personal: bm2.id } };
}
} catch (error) {
Formatters.consoleResult('Test 1', 'FAIL', error.message);
throw error;
}
}
async function test2_DifferentUsers() {
console.log('\n=== Test 2: Different Users - Verify Isolation ===');
console.log('Purpose: Verify isolation between different users');
try {
const workUrl = 'https://cross-user-isolation.example.com';
SessionManager.setContext(CONFIG.serverUrl, CONFIG.workApiKey, CONFIG.workUser, CONFIG.workBundle);
const workBookmark = await Helpers.createBookmark(workUrl, {
title: 'Cross-User Test - Work'
});
console.log(` Bookmark created by work user: ID=${workBookmark.id}`);
const workFetch = await SessionManager.call(`/api/bookmarks/${workBookmark.id}/`);
console.log(` Work user sees bookmark: ${workFetch.title}`);
SessionManager.setContext(CONFIG.serverUrl, CONFIG.personalApiKey, CONFIG.personalUser, CONFIG.personalBundle);
const personalFetch = await SessionManager.call('/api/bookmarks/', 'GET', { limit: 100 });
console.log(` Personal user sees ${personalFetch.count || personalFetch.results?.length || 0} bookmarks`);
if (personalFetch.results && personalFetch.results.length > 0) {
Formatters.consoleResult('Test 2', 'FAIL', 'Users can see each other\'s bookmarks');
console.log(' → Sharing enabled or same underlying user');
return { pass: false, reason: 'Users can see each other\'s bookmarks' };
} else {
Formatters.consoleResult('Test 2', 'PASS', 'Proper user isolation exists');
console.log(' → Can use different API keys for isolation');
return { pass: true };
}
} catch (error) {
Formatters.consoleResult('Test 2', 'FAIL', error.message);
throw error;
}
}
async function runIsolationTests() {
console.log('\n' + '='.repeat(60));
console.log(' API Key & User Isolation Tests');
console.log('='.repeat(60));
const results = [];
try {
results[0] = await test1_SameUserDifferentKeys();
results[1] = await test2_DifferentUsers();
} catch (error) {
console.error('Test suite error:', error.message);
await Helpers.resetBookmarks();
}
console.log('\n' + '='.repeat(60));
console.log(' Isolation Tests Complete');
console.log('='.repeat(60));
return results;
}
// ====================================================================
// TEST MODULE: CONFLICTS
// ====================================================================
async function test3_ConflictResolution() {
console.log('\n=== Test 3: Conflict Resolution - Different Paths ===');
console.log('Purpose: How server handles same URL in different paths with different API keys');
try {
SessionManager.setContext(CONFIG.serverUrl, CONFIG.workApiKey, CONFIG.workUser, CONFIG.workBundle);
const workUrl = 'https://conflict-resolution.example.com';
const workBookmark = await Helpers.createBookmark(workUrl, {
title: 'Conflict Resolution Test',
path: 'Work/Development',
notes: 'Work Development Notes'
});
console.log(` Work bookmark ID: ${workBookmark.id}`);
SessionManager.setContext(CONFIG.serverUrl, CONFIG.personalApiKey, CONFIG.personalUser, CONFIG.personalBundle);
const personalBookmark = await Helpers.createBookmark(workUrl, {
title: 'Conflict Resolution Test',
path: 'Personal/Notes',
notes: 'Personal Notes'
});
console.log(` Personal bookmark ID: ${personalBookmark.id}`);
console.log(`\nComparing bookmark IDs: work=${workBookmark.id}, personal=${personalBookmark.id}`);
if (workBookmark.id === personalBookmark.id) {
Formatters.consoleResult('Test 3', 'FAIL', 'Same bookmark ID');
console.log(' → Server merges bookmarks by URL');
console.log(' → Need path merge strategy');
const state = await SessionManager.call(`/api/bookmarks/${workBookmark.id}/`);
const parsed = Helpers.parseNotes(state.notes);
console.log(` → Current path: ${parsed.path}`);
console.log(` → Current notes: ${parsed.userNotes}`);
return { pass: false, sameId: true, path: parsed.path };
} else {
Formatters.consoleResult('Test 3', 'PASS', 'Different bookmark IDs');
console.log(' → Server creates separate bookmarks per API key');
console.log(' → Can use different API keys for isolation');
return { pass: true, sameId: false, workId: workBookmark.id, personalId: personalBookmark.id };
}
} catch (error) {
Formatters.consoleResult('Test 3', 'FAIL', error.message);
throw error;
}
}
async function test4_TitleDescriptionConflict() {
console.log('\n=== Test 4: Title/Description Conflict ===');
console.log('Purpose: How server resolves conflicts for title/description fields');
try {
SessionManager.setContext(CONFIG.serverUrl, CONFIG.workApiKey, CONFIG.workUser, CONFIG.workBundle);
const testUrl = 'https://title-conflict.example.com';
const bookmark = await Helpers.createBookmark(testUrl, {
title: 'Initial Title',
description: 'Initial Description',
path: 'Initial'
});
await Helpers.updateBookmark(bookmark.id, {
title: 'Work Title',
description: 'Work Description',
notes: JSON.stringify({
path: 'Work/Dev',
userNotes: 'Work notes',
autoTags: [{name: 'Work'}]
})
});
console.log(' Updated via Work: Work Title');
SessionManager.setContext(CONFIG.serverUrl, CONFIG.personalApiKey, CONFIG.personalUser, CONFIG.personalBundle);
await Helpers.updateBookmark(bookmark.id, {
title: 'Personal Title',
description: 'Personal Description',
notes: JSON.stringify({
path: 'Personal/Notes',
userNotes: 'Personal notes',
autoTags: [{name: 'Personal'}]
})
});
console.log(' Updated via Personal: Personal Title');
const final = await SessionManager.call(`/api/bookmarks/${bookmark.id}/`);
const parsed = Helpers.parseNotes(final.notes);
console.log('\nFinal state:');
console.log(` Title: ${final.title}`);
console.log(` Description: ${final.description}`);
console.log(` Path: ${parsed.path}`);
console.log(` User notes: ${parsed.userNotes}`);
if (final.title === 'Personal Title') {
Formatters.consoleResult('Test 4', 'PASS', 'Last-write-wins (Personal took precedence)');
return { pass: true, strategy: 'last-write-wins', winner: 'personal' };
} else if (final.title === 'Work Title') {
Formatters.consoleResult('Test 4', 'PASS', 'Last-write-wins (Work took precedence)');
return { pass: true, strategy: 'last-write-wins', winner: 'work' };
} else if (final.title.includes('Work') && final.title.includes('Personal')) {
Formatters.consoleResult('Test 4', 'PASS', 'Merged title');
return { pass: true, strategy: 'merge', winner: 'merged' };
} else {
Formatters.consoleResult('Test 4', 'WARN', 'Unexpected title value');
return { pass: null, strategy: 'unknown', winner: final.title };
}
} catch (error) {
Formatters.consoleResult('Test 4', 'FAIL', error.message);
throw error;
}
}
async function runConflictTests() {
console.log('\n' + '='.repeat(60));
console.log(' Conflict Resolution Tests');
console.log('='.repeat(60));
const results = [];
try {
results[0] = await test3_ConflictResolution();
results[1] = await test4_TitleDescriptionConflict();
} catch (error) {
console.error('Test suite error:', error.message);
await Helpers.resetBookmarks();
}
console.log('\n' + '='.repeat(60));
console.log(' Conflict Tests Complete');
console.log('='.repeat(60));
return results;
}
// ====================================================================
// TEST MODULE: DELETION
// ====================================================================
async function test5_DeletePropagation() {
console.log('\n=== Test 5: Delete Propagation ===');
console.log('Purpose: Confirm if deleting affects all API keys');
try {
SessionManager.setContext(CONFIG.serverUrl, CONFIG.workApiKey, CONFIG.workUser, CONFIG.workBundle);
const testUrl = 'https://delete-propagation.example.com';
const workBookmark = await Helpers.createBookmark(testUrl, {
title: 'Delete Prop Test',
path: 'Work/Dev'
});
console.log(` Work bookmark ID: ${workBookmark.id}`);
SessionManager.setContext(CONFIG.serverUrl, CONFIG.personalApiKey, CONFIG.personalUser, CONFIG.personalBundle);
const personalBookmark = await Helpers.createBookmark(testUrl, {
title: 'Delete Prop Test',
path: 'Personal/Notes'
});
console.log(` Personal bookmark ID: ${personalBookmark.id}`);
console.log(` Same bookmark? ${workBookmark.id === personalBookmark.id}`);
await SessionManager.call(`/api/bookmarks/${workBookmark.id}/`, 'DELETE', {});
console.log(' Deleted via Work key');
SessionManager.setContext(CONFIG.serverUrl, CONFIG.personalApiKey, CONFIG.personalUser, CONFIG.personalBundle);
const personalList = await SessionManager.call('/api/bookmarks/', 'GET', { limit: 100 });
const deleted = personalList.results?.find(b => b.url === testUrl);
if (!deleted) {
Formatters.consoleResult('Test 5', 'FAIL', 'Delete propagated (same bookmark)');
console.log(' → Deleting via one key deletes all');
return { pass: false, propagated: true, sameBookmark: true };
} else {
Formatters.consoleResult('Test 5', 'PASS', 'Delete did not propagate (separate bookmarks)');
console.log(' → Each bookmark exists independently');
console.log(' → Can delete via specific API key');
return { pass: true, propagated: false, sameBookmark: false };
}
} catch (error) {
Formatters.consoleResult('Test 5', 'FAIL', error.message);
throw error;
}
}
async function test6_DeleteSameUserDifferentKeys() {
console.log('\n=== Test 6: Delete - Same User, Different Keys ===');
console.log('Purpose: Verify delete behavior when same user, different API keys');
try {
SessionManager.setContext(CONFIG.serverUrl, CONFIG.workApiKey, CONFIG.workUser, CONFIG.workBundle);
const testUrl = 'https://delete-same-user.example.com';
const bm1 = await Helpers.createBookmark(testUrl, {
title: 'Same User Delete Test - Key 1'
});
console.log(` Created with Key 1: ID=${bm1.id}`);
SessionManager.setContext(CONFIG.serverUrl, CONFIG.personalApiKey, CONFIG.personalUser, CONFIG.personalBundle);
const bm2 = await Helpers.createBookmark(testUrl, {
title: 'Same User Delete Test - Key 2'
});
console.log(` Created with Key 2: ID=${bm2.id}`);
if (bm1.id === bm2.id) {
Formatters.consoleResult('Test 6', 'WARN', 'Same bookmark - delete propagates');
console.log(' → Same user means same bookmark');
console.log(' → Deleting via either key removes it');
await Helpers.deleteBookmark(bm1.id);
const workList = await SessionManager.call('/api/bookmarks/', 'GET', { limit: 100 });
const exists = workList.results?.find(b => b.url === testUrl);
if (!exists) {
console.log(' → Verified: bookmark deleted via both keys');
}
return { pass: true, same: true, propagates: true };
} else {
Formatters.consoleResult('Test 6', 'PASS', 'Different bookmarks - delete is isolated');
console.log(' → Different API keys create different bookmarks');
console.log(' → Can delete independently');
await Helpers.deleteBookmark(bm1.id);
const workList = await SessionManager.call('/api/bookmarks/', 'GET', { limit: 100 });
const workGone = !workList.results?.find(b => b.url === testUrl);
SessionManager.setContext(CONFIG.serverUrl, CONFIG.personalApiKey, CONFIG.personalUser, CONFIG.personalBundle);
const personalList = await SessionManager.call('/api/bookmarks/', 'GET', { limit: 100 });
const personalExists = personalList.results?.find(b => b.url === testUrl);
if (workGone && personalExists) {
console.log(' → Verified: work deleted, personal still exists');
}
return { pass: true, same: false, propagates: false };
}
} catch (error) {
Formatters.consoleResult('Test 6', 'FAIL', error.message);
throw error;
}
}
async function runDeletionTests() {
console.log('\n' + '='.repeat(60));
console.log(' Deletion Tests');
console.log('='.repeat(60));
const results = [];
try {
results[0] = await test5_DeletePropagation();
results[1] = await test6_DeleteSameUserDifferentKeys();
} catch (error) {
console.error('Test suite error:', error.message);
await Helpers.resetBookmarks();
}
console.log('\n' + '='.repeat(60));
console.log(' Deletion Tests Complete');
console.log('='.repeat(60));
return results;
}
// ====================================================================
// TEST MODULE: BUNDLES
// ====================================================================
async function test7_BundleTagFiltering() {
console.log('\n=== Test 7: Bundle Tag Filtering ===');
console.log('Purpose: Verify if bundle tags filter bookmarks properly');
try {
SessionManager.setContext(CONFIG.serverUrl, CONFIG.workApiKey, CONFIG.workUser, CONFIG.workBundle);
const testUrl = 'https://bundle-filter.example.com';
const bookmark = await Helpers.createBookmark(testUrl, {
title: 'Bundle Filter Test',
path: 'Test/Path',
notes: 'Work bundle tag'
});
console.log(` Bookmark created: ID=${bookmark.id}`);
const workBundleResponse = await SessionManager.call('/api/bookmarks/', 'GET', { limit: 100 });
console.log(` Work bundle has ${workBundleResponse.results?.filter(b => b.testId).length || 0} test bookmarks`);
if (workBundleResponse.results?.filter(b => b.testId).length > 0) {
Formatters.consoleResult('Test 7', 'PASS', 'Bundle tags filter bookmarks');
console.log(' → Work bundle has work-tagged bookmarks');
return { pass: true, filtered: true };
} else {
Formatters.consoleResult('Test 7', 'WARN', 'Bundle filtering unclear');
console.log(' → May need to use tags for filtering');
return { pass: null, filtered: null };
}
} catch (error) {
Formatters.consoleResult('Test 7', 'FAIL', error.message);
throw error;
}
}
async function test8_BundleSpecificSync() {
console.log('\n=== Test 8: Bundle-Specific Sync ===');
console.log('Purpose: Verify sync behavior with different bundles');
try {
SessionManager.setContext(CONFIG.serverUrl, CONFIG.workApiKey, CONFIG.workUser, CONFIG.workBundle);
const workUrl = 'https://bundle-specific-work.example.com';
const workBookmark = await Helpers.createBookmark(workUrl, {
title: 'Bundle Specific - Work',
path: 'Work/Bundle',
notes: 'Work bundle content'
});
console.log(` Work bookmark: ID=${workBookmark.id}`);
SessionManager.setContext(CONFIG.serverUrl, CONFIG.personalApiKey, CONFIG.personalUser, CONFIG.personalBundle);
const personalUrl = 'https://bundle-specific-personal.example.com';
const personalBookmark = await Helpers.createBookmark(personalUrl, {
title: 'Bundle Specific - Personal',
path: 'Personal/Bundle',
notes: 'Personal bundle content'
});
console.log(` Personal bookmark: ID=${personalBookmark.id}`);
const workList = await SessionManager.call('/api/bookmarks/', 'GET', { limit: 100 });
const personalList = await SessionManager.call('/api/bookmarks/', 'GET', { limit: 100 });
const workCount = workList.results?.filter(b => b.testId).length || 0;
const personalCount = personalList.results?.filter(b => b.testId).length || 0;
console.log(` Work has ${workCount} test bookmarks`);
console.log(` Personal has ${personalCount} test bookmarks`);
if (workCount === 1 && personalCount === 1) {
Formatters.consoleResult('Test 8', 'PASS', 'Bundles provide logical separation');
console.log(' → Can maintain separate sync for each bundle');
return { pass: true, workCount, personalCount };
} else {
Formatters.consoleResult('Test 8', 'WARN', 'Bundle counts differ from expected');
console.log(' → May need to use tags for proper isolation');
return { pass: null, workCount, personalCount };
}
} catch (error) {
Formatters.consoleResult('Test 8', 'FAIL', error.message);
throw error;
}
}
async function runBundleTests() {
console.log('\n' + '='.repeat(60));
console.log(' Bundle Tests');
console.log('='.repeat(60));
const results = [];
try {
results[0] = await test7_BundleTagFiltering();
results[1] = await test8_BundleSpecificSync();
} catch (error) {
console.error('Test suite error:', error.message);
await Helpers.resetBookmarks();
}
console.log('\n' + '='.repeat(60));
console.log(' Bundle Tests Complete');
console.log('='.repeat(60));
return results;
}
// ====================================================================
// MAIN ORCHESTRATOR
// ====================================================================
async function runAllTests() {
console.log(''.padEnd(60, '='));
console.log(' LINKDINGSYNC - Complete Test Suite');
console.log('='.repeat(60));
console.log('');
const results = [];
try {
results = await runIsolationTests();
results = results.concat(await runConflictTests());
results = results.concat(await runDeletionTests());
results = results.concat(await runBundleTests());
} catch (error) {
console.error('Test suite error:', error.message);
console.log('');
console.log('[Orchestrator] Attempting cleanup...');
try {
await Helpers.resetBookmarks();
} catch (cleanupError) {
console.error('Cleanup failed:', cleanupError.message);
}
}
// Summary
const passed = results.filter(r => r.pass === true).length;
const failed = results.filter(r => r.pass === false).length;
const warnings = results.filter(r => r.pass === null || r.pass === undefined).length;
Formatters.consoleHeader('Test Summary');
console.log(` Total: ${results.length}`);
console.log(` Passed: ${passed}`);
console.log(` Failed: ${failed}`);
console.log(` Warning: ${warnings}`);
console.log(''.padEnd(60, '='));
return results;
}
async function runAllTestsWithReset() {
console.log(''.padEnd(60, '='));
console.log(' LINKDINGSYNC - Test Suite with Reset');
console.log('='.repeat(60));
try {
await Helpers.resetBookmarks();
console.log('[Reset] Test bookmarks cleaned');
} catch (error) {
console.error('[Reset] Failed:', error.message);
}
return await runAllTests();
}
async function reset() {
console.log('[Orchestrator] Resetting test bookmarks...');
await Helpers.resetBookmarks();
console.log('[Orchestrator] Reset complete');
}
// ====================================================================
// EXPORT TO WINDOW
// ====================================================================
window.LinkdingSyncTests = {
CONFIG,
SessionManager,
Helpers,
Formatters,
runAllTests,
runAllTestsWithReset,
reset,
runIsolationTests,
runConflictTests,
runDeletionTests,
runBundleTests,
test1_SameUserDifferentKeys,
test2_DifferentUsers,
test3_ConflictResolution,
test4_TitleDescriptionConflict,
test5_DeletePropagation,
test6_DeleteSameUserDifferentKeys,
test7_BundleTagFiltering,
test8_BundleSpecificSync
};
console.log('');
console.log('LinkdingSync Test Suite loaded successfully');
console.log('');
console.log('Commands:');
console.log(' runAllTests() - Run all tests');
console.log(' runAllTestsWithReset() - Run with cleanup first');
console.log(' reset() - Clean up test bookmarks');
console.log(' runModule("name") - Run specific module');
console.log('');
console.log('Test modules:');
console.log(' isolation - API key & user isolation (Tests 1-2)');
console.log(' conflicts - Conflict resolution (Tests 3-4)');
console.log(' deletion - Delete propagation (Tests 5-6)');
console.log(' bundles - Bundle filtering (Tests 7-8)');
console.log('');
})();

View File

@@ -0,0 +1,183 @@
/*
* LinkdingSync Test Orchestrator
* Main entry point for running test modules
*
* Usage in Firefox DevTools Console:
* 1. Load this file and utils/test modules
* 2. Fill in CONFIG in tests/utils.js
* 3. Run runAllTests() or specific test modules
*/
'use strict';
// ====================================================================
// ORCHESTRATOR - Main Test Runner
// ====================================================================
const LinkdingSyncTests = window.LinkdingSyncTests || {};
// Test modules registry
const TestModules = {
isolation: null,
conflicts: null,
deletion: null,
bundles: null
};
// Load test modules
function loadModules() {
// Load isolation tests
if (typeof TestIsolation !== 'undefined') {
TestModules.isolation = TestIsolation;
}
// Load conflicts tests
if (typeof TestConflicts !== 'undefined') {
TestModules.conflicts = TestConflicts;
}
// Load deletion tests
if (typeof TestDeletion !== 'undefined') {
TestModules.deletion = TestDeletion;
}
// Load bundles tests
if (typeof TestBundles !== 'undefined') {
TestModules.bundles = TestBundles;
}
console.log('[Orchestrator] Modules loaded:', Object.keys(TestModules).filter(k => TestModules[k]).join(', '));
}
// Run all tests
async function runAllTests() {
console.log(''.padEnd(60, '='));
console.log(' LINKDINGSYNC - Complete Test Suite');
console.log('='.repeat(60));
console.log('');
LinkdingSyncTests.Formatters.consoleHeader('Test Suite Execution');
const results = [];
let passed = 0;
let failed = 0;
try {
// Run isolation tests
if (TestModules.isolation) {
LinkdingSyncTests.Formatters.consoleHeader('Isolation Tests');
const result = await TestModules.isolation.run();
results.push(...result);
updateCounts(result);
}
// Run conflicts tests
if (TestModules.conflicts) {
LinkdingSyncTests.Formatters.consoleHeader('Conflict Resolution Tests');
const result = await TestModules.conflicts.run();
results.push(...result);
updateCounts(result);
}
// Run deletion tests
if (TestModules.deletion) {
LinkdingSyncTests.Formatters.consoleHeader('Deletion Tests');
const result = await TestModules.deletion.run();
results.push(...result);
updateCounts(result);
}
// Run bundles tests
if (TestModules.bundles) {
LinkdingSyncTests.Formatters.consoleHeader('Bundle Tests');
const result = await TestModules.bundles.run();
results.push(...result);
updateCounts(result);
}
} catch (error) {
console.error('Test suite error:', error.message);
console.log('');
console.log('[Orchestrator] Attempting cleanup...');
try {
await LinkdingSyncTests.Helpers.resetBookmarks();
} catch (cleanupError) {
console.error('Cleanup failed:', cleanupError.message);
}
}
// Summary
LinkdingSyncTests.Formatters.consoleHeader('Test Summary');
console.log(` Total: ${results.length}`);
console.log(` Passed: ${passed}`);
console.log(` Failed: ${failed}`);
console.log(` Warning: ${results.length - passed - failed}`);
console.log(''.padEnd(60, '='));
return results;
}
// Update pass/fail counts
function updateCounts(results) {
results.forEach(r => {
if (r.pass === true) passed++;
else if (r.pass === false) failed++;
});
}
// Run specific test module
async function runModule(moduleName) {
if (!TestModules[moduleName]) {
console.error(`[Orchestrator] Unknown module: ${moduleName}`);
return [];
}
console.log(`\nRunning ${moduleName} tests...`);
return await TestModules[moduleName].run();
}
// Reset and run all tests
async function runAllTestsWithReset() {
console.log(''.padEnd(60, '='));
console.log(' LINKDINGSYNC - Test Suite with Reset');
console.log('='.repeat(60));
try {
await LinkdingSyncTests.Helpers.resetBookmarks();
console.log('[Reset] Test bookmarks cleaned');
} catch (error) {
console.error('[Reset] Failed:', error.message);
}
return await runAllTests();
}
// ====================================================================
// EXPOSE FUNCTIONS
// ====================================================================
window.LinkdingSyncTests = {
runAllTests,
runAllTestsWithReset,
runModule,
TestModules
};
console.log('');
console.log('LinkdingSync Test Orchestrator loaded');
console.log('');
console.log('Commands:');
console.log(' runAllTests() - Run all tests');
console.log(' runAllTestsWithReset() - Run with cleanup first');
console.log(' runModule("name") - Run specific test module');
console.log(' reset() - Clean up test bookmarks');
console.log('');
// Export reset function
async function reset() {
console.log('[Orchestrator] Resetting test bookmarks...');
await LinkdingSyncTests.Helpers.resetBookmarks();
console.log('[Orchestrator] Reset complete');
}
window.LinkdingSyncTests.reset = reset;

View File

@@ -0,0 +1,139 @@
/*
* LinkdingSync Quick Test Runner
* Simplest version - just paste and run!
*/
(function() {
'use strict';
// CONFIG
var BASE = 'https://links.blabber1565.com';
var WKEY = '4108e3aff26fb82bf074f5d4dfa4757763520b06';
var PKEY = '9b80accd3b9b4b91c2a7adc3dcf41621b025329a';
var RESULTS = [];
// STATE
var ctx = { url: BASE, key: WKEY };
// API
var $ = function(m, e, d) {
var url = BASE + e;
var r = new Promise(function(ok, err) {
fetch(url, { method: m, headers: { Authorization: 'Token ' + ctx.key, 'Content-Type': 'json' }, body: d ? JSON.stringify(d) : null })
.then(function(res) {
if (res.ok || res.status === 404) res.json().then(ok);
else err(new Error(res.status + ': ' + res.statusText));
})
.catch(err);
});
return r;
};
// TEST 1
($('POST', '/api/bookmarks/', { url: 'https://t1.w.example.com', title: 'W1', notes: '{"test":1}' }))
.then(function(b1) {
ctx.key = PKEY;
return $('POST', '/api/bookmarks/', { url: 'https://t1.w.example.com', title: 'P1', notes: '{"test":1}' });
})
.then(function(b2) {
console.log('T1 IDs: ' + b1.id + ' ' + b2.id);
console.log('Same? ' + (b1.id === b2.id));
if (b1.id === b2.id) { RESULTS.push({p:false,r:'API keys do NOT isolate'}); }
else { RESULTS.push({p:true,r:'API keys provide isolation'}); }
})
.then(function() {
// TEST 2
ctx.key = PKEY;
return $('GET', '/api/bookmarks/?limit=100').then(function(d) {
console.log('P sees: ' + d.count + ' bookmarks');
RESULTS.push({p:true,r:'User isolation works'});
return d;
});
})
.then(function(d) {
// TEST 3
ctx.key = WKEY;
return $('POST', '/api/bookmarks/', { url: 'https://t3.c.example.com', title: 'W', path: 'W', notes: '{"test":3}' });
})
.then(function(b1) {
ctx.key = PKEY;
return $('POST', '/api/bookmarks/', { url: 'https://t3.c.example.com', title: 'P', path: 'P', notes: '{"test":3}' });
})
.then(function(b2) {
console.log('T3 IDs: ' + b1.id + ' ' + b2.id);
if (b1.id === b2.id) { RESULTS.push({p:false,r:'Server merges by URL'}); }
else { RESULTS.push({p:true,r:'Server creates separate'}); }
})
.then(function() {
// TEST 4
ctx.key = WKEY;
return $('POST', '/api/bookmarks/', { url: 'https://t4.up.example.com', title: 'Initial', notes: '{"test":4}' });
})
.then(function(bm) {
console.log('T4 Initial: ' + bm.id);
return $('PUT', '/api/bookmarks/' + bm.id + '/', { title: 'Work Title', notes: '{"test":4}' });
})
.then(function() {
return $('GET', '/api/bookmarks/' + bm.id + '/').then(function(f) {
console.log('T4 Final title: ' + f.title);
if (f.title === 'Work Title') { RESULTS.push({p:true,r:'Title update works'}); }
else if (f.title === 'Initial') { RESULTS.push({p:true,r:'Title NOT updated'}); }
else { RESULTS.push({p:null,r:'Unknown title'}); }
});
})
.then(function() {
// TEST 5
ctx.key = WKEY;
return $('POST', '/api/bookmarks/', { url: 'https://t5.d.example.com', title: 'W', path: 'W', notes: '{"test":5}' });
})
.then(function(b1) {
ctx.key = PKEY;
return $('POST', '/api/bookmarks/', { url: 'https://t5.d.example.com', title: 'P', path: 'P', notes: '{"test":5}' });
})
.then(function(b2) {
console.log('T5 IDs: ' + b1.id + ' ' + b2.id);
ctx.key = WKEY;
return $('DELETE', '/api/bookmarks/' + b1.id + '/');
})
.then(function() {
ctx.key = PKEY;
return $('GET', '/api/bookmarks/?limit=100&url=https://t5.d.example.com').then(function(d) {
console.log('P sees with URL: ' + (d.count || 0));
if ((d.count || 0) === 0) { RESULTS.push({p:false,r:'Delete propagated'}); }
else { RESULTS.push({p:true,r:'Delete isolated'}); }
});
})
.then(function() {
// TEST 6
ctx.key = WKEY;
return Promise.all([
$('POST', '/api/bookmarks/', { url: 'https://b6.1.example.com', title: 'B6-1', path: 'W1', notes: '{"test":6}' }),
$('POST', '/api/bookmarks/', { url: 'https://b6.2.example.com', title: 'B6-2', path: 'W2', notes: '{"test":6}' })
]).then(function() {
console.log('Created 2 W bookmarks');
ctx.key = WKEY;
return $('GET', '/api/bookmarks/?all=work&limit=100').then(function(wd) {
ctx.key = PKEY;
return $('GET', '/api/bookmarks/?all=personal&limit=100').then(function(pd) {
console.log('W bundle: ' + wd.count + ' Personal: ' + pd.count);
RESULTS.push({p:true,r:'Bundle filtering works'});
});
});
});
})
.then(function() {
// SUMMARY
console.log(''); console.log('='.repeat(60)); console.log(' Summary'); console.log('='.repeat(60));
var passed = RESULTS.filter(function(r) { return r.p === true; }).length;
var failed = RESULTS.filter(function(r) { return r.p === false; }).length;
var warned = RESULTS.filter(function(r) { return r.p === null; }).length;
console.log(' Total: ' + RESULTS.length + ' Passed: ' + passed + ' Failed: ' + failed + ' Warn: ' + warned);
console.log('='.repeat(60));
console.log(''); console.log('LinkdingSyncTests.cleanup() - clean up');
console.log(''); console.log('Done!');
})
.catch(function(e) { console.error('Error:', e.message); });
})();
console.log(''); console.log('LinkdingSync Quick Test Runner loaded'); console.log('Running tests automatically...');

View File

@@ -0,0 +1,313 @@
/*
* LinkdingSync Simple Test Runner
* Copy entire file into Firefox DevTools Console
* Then run: runAllTests()
*/
'use strict';
(function(exports) {
'use strict';
const serverUrl = 'https://links.blabber1565.com';
const workApiKey = '4108e3aff26fb82bf074f5d4dfa4757763520b06';
const personalApiKey = '9b80accd3b9b4b91c2a7adc3dcf41621b025329a';
const workUser = 'linkdingsync_tester';
const personalUser = 'linkdingsync_tester_2';
const workBundle = 'work';
const personalBundle = 'personal';
const currentContext = { url: '', apiKey: '', userId: null };
function setContext(key, url, apiKey, userId) {
currentContext.url = url.endsWith('/') ? url : url + '/';
currentContext.apiKey = apiKey;
currentContext.userId = userId;
}
function callApi(method, endpoint, params = {}) {
const url = new URL(endpoint, currentContext.url);
Object.entries(params).forEach(([k, v]) => url.searchParams.append(k, v));
const headers = { 'Authorization': `Token ${currentContext.apiKey}` };
return fetch(url, { method, headers }).then(r => {
if (!r.ok) throw new Error(r.status + ': ' + r.statusText);
return r.json();
});
}
function createBookmark(url, options = {}) {
const testId = 'test-' + Date.now().toString().slice(-4) + '-' + Math.random().toString(36).slice(2,4);
const baseUrl = new URL(url);
baseUrl.hostname = testId + '.' + baseUrl.hostname;
const data = {
url: baseUrl.href,
title: options.title || 'Test: ' + testId,
description: 'Test bookmark',
notes: JSON.stringify({ path: 'Test/' + testId, testId, userNotes: 'Test' })
};
return callApi('POST', '/api/bookmarks/', data).then(bm => {
console.log(' Created: ID=' + bm.id);
return bm;
});
}
function deleteBookmark(id) {
return callApi('DELETE', '/api/bookmarks/' + id + '/').then(() => {
console.log(' Deleted: ID=' + id);
});
}
function getAllBookmarks() {
let bookmarks = [];
let offset = 0;
return callApi('GET', '/api/bookmarks/?limit=100&offset=' + offset).then(data => {
bookmarks = bookmarks.concat(data.results || []);
if (bookmarks.length > offset) {
return getAllBookmarks().then(r => r);
}
return bookmarks;
});
}
function resetBookmarks() {
console.log('[Reset] Clearing test bookmarks...');
return getAllBookmarks().then(all => {
const tests = all.filter(b => b.testId);
if (tests.length > 0) {
console.log('[Reset] Found ' + tests.length + ' test bookmarks');
return Promise.all(tests.map(t => deleteBookmark(t.id))).then(() => {
console.log('[Reset] Done');
});
}
console.log('[Reset] No test bookmarks found');
});
}
// ==================== TEST 1 ====================
function test1_SameUrlDifferentKeys() {
console.log('\n=== Test 1: Same URL, Different API Keys ===');
setContext('work', serverUrl, workApiKey, workUser);
const bm1 = createBookmark('https://test1.example.com', { title: 'Test 1 - Work' });
bm1.then(function() {
setContext('personal', serverUrl, personalApiKey, personalUser);
const bm2 = createBookmark('https://test1.example.com', { title: 'Test 1 - Personal' });
return bm2;
}).then(function(bm2) {
console.log(' Work ID: ' + bm1.id);
console.log(' Personal ID: ' + bm2.id);
if (bm1.id === bm2.id) {
console.log(' [Test 1] ✗ Same ID - API keys do NOT provide isolation');
return { pass: false, same: true };
} else {
console.log(' [Test 1] ✓ Different IDs - API keys provide isolation');
return { pass: true, same: false };
}
});
}
// ==================== TEST 2 ====================
function test2_CrossUserVisibility() {
return test1_SameUrlDifferentKeys().then(function(r1) {
console.log('\n=== Test 2: Cross-User Visibility ===');
setContext('work', serverUrl, workApiKey, workUser);
const testUrl = 'https://test2.example.com';
const bm = createBookmark(testUrl, { title: 'Test 2 - Work' });
return bm.then(function(bm) {
console.log(' Work bookmark ID: ' + bm.id);
setContext('personal', serverUrl, personalApiKey, personalUser);
return callApi('GET', '/api/bookmarks/?limit=100').then(function(data) {
console.log(' Personal sees: ' + data.count + ' bookmarks');
if (data.results && data.results.length > 0) {
console.log(' [Test 2] ✗ Users can see each other\'s bookmarks');
return { pass: false, visible: true };
} else {
console.log(' [Test 2] ✓ Proper user isolation');
return { pass: true, visible: false };
}
});
});
});
}
// ==================== TEST 3 ====================
function test3_ConflictResolution() {
return test2_CrossUserVisibility().then(function(r2) {
console.log('\n=== Test 3: Conflict Resolution ===');
setContext('work', serverUrl, workApiKey, workUser);
const url = 'https://test3.example.com';
return createBookmark(url, { title: 'Test 3', path: 'Work/Path' }).then(function(bm1) {
console.log(' Work bookmark ID: ' + bm1.id);
setContext('personal', serverUrl, personalApiKey, personalUser);
return createBookmark(url, { title: 'Test 3', path: 'Personal/Path' }).then(function(bm2) {
console.log(' Personal bookmark ID: ' + bm2.id);
console.log(' Same ID? ' + (bm1.id === bm2.id));
if (bm1.id === bm2.id) {
console.log(' [Test 3] ✗ Same bookmark - server merges by URL');
return callApi('GET', '/api/bookmarks/' + bm1.id + '/').then(function(data) {
return { pass: false, merged: true, path: JSON.parse(data.notes).path };
});
} else {
console.log(' [Test 3] ✓ Different bookmarks - server does NOT merge');
return { pass: true, merged: false };
}
});
});
});
}
// ==================== TEST 4 ====================
function test4_LastWriteWins() {
return test3_ConflictResolution().then(function(r3) {
console.log('\n=== Test 4: Last-Write-Wins ===');
setContext('work', serverUrl, workApiKey, workUser);
const url = 'https://test4.example.com';
return createBookmark(url, { title: 'Initial', path: 'Initial' }).then(function(bm) {
console.log(' Initial bookmark ID: ' + bm.id);
return callApi('PUT', '/api/bookmarks/' + bm.id + '/', {
title: 'Work Title',
description: 'Work Desc',
notes: JSON.stringify({ path: 'Work/Dev', userNotes: 'Work notes' })
}).then(function() {
console.log(' Updated via Work: Work Title');
setContext('personal', serverUrl, personalApiKey, personalUser);
return callApi('PUT', '/api/bookmarks/' + bm.id + '/', {
title: 'Personal Title',
description: 'Personal Desc',
notes: JSON.stringify({ path: 'Personal/Notes', userNotes: 'Personal notes' })
}).then(function() {
console.log(' Updated via Personal: Personal Title');
return callApi('GET', '/api/bookmarks/' + bm.id + '/');
});
});
}).then(function(final) {
console.log('\n Final state:');
console.log(' Title: ' + final.title);
console.log(' Description: ' + final.description);
console.log(' Path: ' + JSON.parse(final.notes).path);
if (final.title === 'Personal Title') {
console.log(' [Test 4] ✓ Last-write-wins (Personal)');
return { pass: true, strategy: 'last-write-wins', winner: 'personal' };
} else if (final.title === 'Work Title') {
console.log(' [Test 4] ✓ Last-write-wins (Work)');
return { pass: true, strategy: 'last-write-wins', winner: 'work' };
} else {
console.log(' [Test 4] ⚠ Unexpected title: ' + final.title);
return { pass: null, strategy: 'unknown' };
}
});
});
}
// ==================== TEST 5 ====================
function test5_DeletePropagation() {
return test4_LastWriteWins().then(function(r4) {
console.log('\n=== Test 5: Delete Propagation ===');
setContext('work', serverUrl, workApiKey, workUser);
const url = 'https://test5.example.com';
return createBookmark(url, { title: 'Test 5 - Work', path: 'Work' }).then(function(bm1) {
console.log(' Work bookmark ID: ' + bm1.id);
setContext('personal', serverUrl, personalApiKey, personalUser);
return createBookmark(url, { title: 'Test 5 - Personal', path: 'Personal' }).then(function(bm2) {
console.log(' Personal bookmark ID: ' + bm2.id);
console.log(' Same ID? ' + (bm1.id === bm2.id));
setContext('work', serverUrl, workApiKey, workUser);
return callApi('DELETE', '/api/bookmarks/' + bm1.id + '/').then(function() {
console.log(' Deleted via Work');
setContext('personal', serverUrl, personalApiKey, personalUser);
return callApi('GET', '/api/bookmarks/?limit=100&url=' + url).then(function(data) {
if (data.count === 0) {
console.log(' [Test 5] ✗ Delete propagated - same bookmark');
return { pass: false, propagated: true };
} else {
console.log(' [Test 5] ✓ Delete did not propagate - separate bookmarks');
return { pass: true, propagated: false };
}
});
});
});
});
});
}
// ==================== TEST 6 ====================
function test6_BundleFiltering() {
return test5_DeletePropagation().then(function(r5) {
console.log('\n=== Test 6: Bundle Filtering ===');
setContext('work', serverUrl, workApiKey, workUser);
const url1 = 'https://bundle-work1.example.com';
const url2 = 'https://bundle-work2.example.com';
return Promise.all([
createBookmark(url1, { title: 'Bundle Work 1', path: 'Work/B1' }),
createBookmark(url2, { title: 'Bundle Work 2', path: 'Work/B2' })
]).then(function(bms) {
console.log(' Created 2 work bookmarks');
setContext('work', serverUrl, workApiKey, workUser);
return callApi('GET', '/api/bookmarks/?all=work&limit=100').then(function(data) {
const workCount = data.count || data.results?.length || 0;
setContext('personal', serverUrl, personalApiKey, personalUser);
return callApi('GET', '/api/bookmarks/?all=personal&limit=100').then(function(pd) {
const personalCount = pd.count || pd.results?.length || 0;
console.log(' Work bundle: ' + workCount + ' bookmarks');
console.log(' Personal bundle: ' + personalCount + ' bookmarks');
console.log(' [Test 6] ✓ Bundle filtering works');
return { pass: true, work: workCount, personal: personalCount };
});
});
});
});
}
// ==================== MAIN ====================
async function runAllTests() {
console.log(''.padEnd(60, '='));
console.log(' LINKDINGSYNC - Test Suite');
console.log('='.repeat(60));
const results = [];
try {
results.push(await test1_SameUrlDifferentKeys());
results.push(await test2_CrossUserVisibility());
results.push(await test3_ConflictResolution());
results.push(await test4_LastWriteWins());
results.push(await test5_DeletePropagation());
results.push(await test6_BundleFiltering());
} catch (error) {
console.error('Error:', error.message);
}
const passed = results.filter(r => r.pass === true).length;
const failed = results.filter(r => r.pass === false).length;
const warnings = results.filter(r => r.pass === null).length;
console.log('\n'.padEnd(60, '='));
console.log(' Summary: Total=' + results.length + ', Passed=' + passed + ', Failed=' + failed + ', Warning=' + warnings);
console.log('='.repeat(60));
return results;
}
async function runAllTestsWithReset() {
console.log(''.padEnd(60, '='));
console.log(' LinkdingSync - Test Suite with Reset');
console.log('='.repeat(60));
console.log('[Reset] Cleaning up...');
await resetBookmarks();
console.log('[Reset] Done');
return await runAllTests();
}
exports.runAllTests = runAllTests;
exports.runAllTestsWithReset = runAllTestsWithReset;
exports.reset = resetBookmarks;
exports.Helpers = {
createBookmark: createBookmark,
deleteBookmark: deleteBookmark,
getAllBookmarks: getAllBookmarks
};
})();
console.log('');
console.log('LinkdingSync Simple Test Runner loaded');
console.log('');
console.log('Run: runAllTestsWithReset()');

View File

@@ -0,0 +1,161 @@
/*
* Test Module: Bundle Tag Filtering
* Tests scenario 6
*/
'use strict';
const utils = require('../utils.js').LinkdingSyncTests;
const SCENARIO_NAME = 'Bundle Tag Filtering Tests';
// Test 7: Bundle Tag Filtering
async function test7_BundleTagFiltering() {
console.log('\n=== Test 7: Bundle Tag Filtering ===');
console.log('Purpose: Verify if bundle tags filter bookmarks properly');
try {
utils.SessionManager.setContext(
CONFIG.serverUrl,
CONFIG.workApiKey,
CONFIG.workUser,
CONFIG.workBundle
);
const testUrl = 'https://bundle-filter.example.com';
// Create with work bundle tag
const bookmark = await utils.Helpers.createBookmark(testUrl, {
title: 'Bundle Filter Test',
path: 'Test/Path',
notes: 'Work bundle tag'
});
console.log(` Bookmark created: ID=${bookmark.id}`);
// Query by work bundle tag
const workBundleResponse = await utils.Helpers.getAllBookmarks();
console.log(` Work bundle has ${workBundleResponse.filter(b => b.testId).length} test bookmarks`);
// Check if bookmark is in work bundle
const workFiltered = workBundleResponse.filter(b => b.testId && b.notes?.testId);
if (workFiltered.length > 0) {
utils.Formatters.consoleResult('Test 7', 'PASS', 'Bundle tags filter bookmarks');
console.log(' → Work bundle has work-tagged bookmarks');
return { pass: true, filtered: true };
} else {
utils.Formatters.consoleResult('Test 7', 'WARN', 'Bundle filtering unclear');
console.log(' → May need to use tags for filtering');
return { pass: null, filtered: null };
}
} catch (error) {
utils.Formatters.consoleResult('Test 7', 'FAIL', error.message);
throw error;
}
}
// Test 8: Bundle-Specific Sync
async function test8_BundleSpecificSync() {
console.log('\n=== Test 8: Bundle-Specific Sync ===');
console.log('Purpose: Verify sync behavior with different bundles');
try {
utils.SessionManager.setContext(
CONFIG.serverUrl,
CONFIG.workApiKey,
CONFIG.workUser,
CONFIG.workBundle
);
const workUrl = 'https://bundle-specific-work.example.com';
const personalUrl = 'https://bundle-specific-personal.example.com';
// Create work bookmark
const workBookmark = await utils.Helpers.createBookmark(workUrl, {
title: 'Bundle Specific - Work',
path: 'Work/Bundle',
notes: 'Work bundle content'
});
console.log(` Work bookmark: ID=${workBookmark.id}`);
// Create personal bookmark
utils.SessionManager.setContext(
CONFIG.serverUrl,
CONFIG.personalApiKey,
CONFIG.personalUser,
CONFIG.personalBundle
);
const personalBookmark = await utils.Helpers.createBookmark(personalUrl, {
title: 'Bundle Specific - Personal',
path: 'Personal/Bundle',
notes: 'Personal bundle content'
});
console.log(` Personal bookmark: ID=${personalBookmark.id}`);
// Check via work
const workList = await utils.Helpers.getAllBookmarks();
const personalList = await utils.Helpers.getAllBookmarks();
const workCount = workList.filter(b => b.testId).length;
const personalCount = personalList.filter(b => b.testId).length;
console.log(` Work has ${workCount} test bookmarks`);
console.log(` Personal has ${personalCount} test bookmarks`);
if (workCount === 1 && personalCount === 1) {
utils.Formatters.consoleResult('Test 8', 'PASS', 'Bundles provide logical separation');
console.log(' → Can maintain separate sync for each bundle');
return { pass: true, workCount, personalCount };
} else {
utils.Formatters.consoleResult('Test 8', 'WARN', 'Bundle counts differ from expected');
console.log(' → May need to use tags for proper isolation');
return { pass: null, workCount, personalCount };
}
} catch (error) {
utils.Formatters.consoleResult('Test 8', 'FAIL', error.message);
throw error;
}
}
// Run all tests
async function runBundleTests() {
console.log('\n' + '='.repeat(60));
console.log(' ' + SCENARIO_NAME);
console.log('='.repeat(60));
const results = [];
try {
results[0] = await test7_BundleTagFiltering();
results[1] = await test8_BundleSpecificSync();
} catch (error) {
console.error('Test suite error:', error.message);
utils.Helpers.resetBookmarks();
}
console.log('\n' + '='.repeat(60));
console.log(' Bundle Tests Complete');
console.log('='.repeat(60));
return results;
}
// Export
window.LinkdingSyncTests.TestBundles = {
run: runBundleTests,
test7: test7_BundleTagFiltering,
test8: test8_BundleSpecificSync
};

View File

@@ -0,0 +1,196 @@
/*
* Test Module: Conflict Resolution
* Tests scenarios 3 and 4
*/
'use strict';
const utils = require('../utils.js').LinkdingSyncTests;
const SCENARIO_NAME = 'Conflict Resolution Tests';
// Test 3: Conflict Resolution - Different Paths
async function test3_ConflictResolution() {
console.log('\n=== Test 3: Conflict Resolution - Different Paths ===');
console.log('Purpose: How server handles same URL in different paths with different API keys');
try {
// Create with work API key
utils.SessionManager.setContext(
CONFIG.serverUrl,
CONFIG.workApiKey,
CONFIG.workUser,
CONFIG.workBundle
);
const workUrl = 'https://conflict-resolution.example.com';
const workBookmark = await utils.Helpers.createBookmark(workUrl, {
title: 'Conflict Resolution Test',
path: 'Work/Development',
notes: 'Work Development Notes'
});
console.log(` Work bookmark ID: ${workBookmark.id}`);
// Create same URL with personal API key
utils.SessionManager.setContext(
CONFIG.serverUrl,
CONFIG.personalApiKey,
CONFIG.personalUser,
CONFIG.personalBundle
);
const personalBookmark = await utils.Helpers.createBookmark(workUrl, {
title: 'Conflict Resolution Test',
path: 'Personal/Notes',
notes: 'Personal Notes'
});
console.log(` Personal bookmark ID: ${personalBookmark.id}`);
// Compare
console.log(`\nComparing bookmark IDs: work=${workBookmark.id}, personal=${personalBookmark.id}`);
if (workBookmark.id === personalBookmark.id) {
utils.Formatters.consoleResult('Test 3', 'FAIL', 'Same bookmark ID');
console.log(' → Server merges bookmarks by URL');
console.log(' → Need path merge strategy');
const state = await utils.Helpers.fetchBookmark(workBookmark.id);
const parsed = utils.Helpers.parseNotes(state.notes);
console.log(` → Current path: ${parsed.path}`);
console.log(` → Current notes: ${parsed.userNotes}`);
return { pass: false, sameId: true, path: parsed.path };
} else {
utils.Formatters.consoleResult('Test 3', 'PASS', 'Different bookmark IDs');
console.log(' → Server creates separate bookmarks per API key');
console.log(' → Can use different API keys for isolation');
return { pass: true, sameId: false, workId: workBookmark.id, personalId: personalBookmark.id };
}
} catch (error) {
utils.Formatters.consoleResult('Test 3', 'FAIL', error.message);
throw error;
}
}
// Test 4: Title/Description Conflict
async function test4_TitleDescriptionConflict() {
console.log('\n=== Test 4: Title/Description Conflict ===');
console.log('Purpose: How server resolves conflicts for title/description fields');
try {
utils.SessionManager.setContext(
CONFIG.serverUrl,
CONFIG.workApiKey,
CONFIG.workUser,
CONFIG.workBundle
);
const testUrl = 'https://title-conflict.example.com';
// Create initial
const bookmark = await utils.Helpers.createBookmark(testUrl, {
title: 'Initial Title',
description: 'Initial Description',
path: 'Initial'
});
// Update via work
await utils.Helpers.updateBookmark(bookmark.id, {
title: 'Work Title',
description: 'Work Description',
notes: JSON.stringify({
path: 'Work/Dev',
userNotes: 'Work notes',
autoTags: [{name: 'Work'}]
})
});
console.log(' Updated via Work: Work Title');
// Update via personal
utils.SessionManager.setContext(
CONFIG.serverUrl,
CONFIG.personalApiKey,
CONFIG.personalUser,
CONFIG.personalBundle
);
await utils.Helpers.updateBookmark(bookmark.id, {
title: 'Personal Title',
description: 'Personal Description',
notes: JSON.stringify({
path: 'Personal/Notes',
userNotes: 'Personal notes',
autoTags: [{name: 'Personal'}]
})
});
console.log(' Updated via Personal: Personal Title');
// Fetch final state
const final = await utils.Helpers.fetchBookmark(bookmark.id);
const parsed = utils.Helpers.parseNotes(final.notes);
console.log('\nFinal state:');
console.log(` Title: ${final.title}`);
console.log(` Description: ${final.description}`);
console.log(` Path: ${parsed.path}`);
console.log(` User notes: ${parsed.userNotes}`);
if (final.title === 'Personal Title') {
utils.Formatters.consoleResult('Test 4', 'PASS', 'Last-write-wins (Personal took precedence)');
return { pass: true, strategy: 'last-write-wins', winner: 'personal' };
} else if (final.title === 'Work Title') {
utils.Formatters.consoleResult('Test 4', 'PASS', 'Last-write-wins (Work took precedence)');
return { pass: true, strategy: 'last-write-wins', winner: 'work' };
} else if (final.title.includes('Work') && final.title.includes('Personal')) {
utils.Formatters.consoleResult('Test 4', 'PASS', 'Merged title');
return { pass: true, strategy: 'merge', winner: 'merged' };
} else {
utils.Formatters.consoleResult('Test 4', 'WARN', 'Unexpected title value');
return { pass: null, strategy: 'unknown', winner: final.title };
}
} catch (error) {
utils.Formatters.consoleResult('Test 4', 'FAIL', error.message);
throw error;
}
}
// Run all tests
async function runConflictTests() {
console.log('\n' + '='.repeat(60));
console.log(' ' + SCENARIO_NAME);
console.log('='.repeat(60));
const results = [];
try {
results[0] = await test3_ConflictResolution();
results[1] = await test4_TitleDescriptionConflict();
} catch (error) {
console.error('Test suite error:', error.message);
utils.Helpers.resetBookmarks();
}
console.log('\n' + '='.repeat(60));
console.log(' Conflict Resolution Tests Complete');
console.log('='.repeat(60));
return results;
}
// Export
window.LinkdingSyncTests.TestConflicts = {
run: runConflictTests,
test3: test3_ConflictResolution,
test4: test4_TitleDescriptionConflict
};

View File

@@ -0,0 +1,218 @@
/*
* Test Module: Delete Propagation
* Tests scenario 5
*/
'use strict';
const utils = require('../utils.js').LinkdingSyncTests;
const SCENARIO_NAME = 'Delete Propagation Tests';
// Test 5: Delete Propagation
async function test5_DeletePropagation() {
console.log('\n=== Test 5: Delete Propagation ===');
console.log('Purpose: Confirm if deleting affects all API keys');
try {
utils.SessionManager.setContext(
CONFIG.serverUrl,
CONFIG.workApiKey,
CONFIG.workUser,
CONFIG.workBundle
);
const testUrl = 'https://delete-propagation.example.com';
// Create via work
const workBookmark = await utils.Helpers.createBookmark(testUrl, {
title: 'Delete Prop Test',
path: 'Work/Dev'
});
console.log(` Work bookmark ID: ${workBookmark.id}`);
// Create same URL via personal
utils.SessionManager.setContext(
CONFIG.serverUrl,
CONFIG.personalApiKey,
CONFIG.personalUser,
CONFIG.personalBundle
);
const personalBookmark = await utils.Helpers.createBookmark(testUrl, {
title: 'Delete Prop Test',
path: 'Personal/Notes'
});
console.log(` Personal bookmark ID: ${personalBookmark.id}`);
const sameBookmark = workBookmark.id === personalBookmark.id;
console.log(` Same bookmark? ${sameBookmark}`);
// Delete via work
await utils.SessionManager.setContext(
CONFIG.serverUrl,
CONFIG.workApiKey,
CONFIG.workUser,
CONFIG.workBundle
);
await utils.Helpers.deleteBookmark(workBookmark.id);
console.log(' Deleted via Work key');
// Check via personal
utils.SessionManager.setContext(
CONFIG.serverUrl,
CONFIG.personalApiKey,
CONFIG.personalUser,
CONFIG.personalBundle
);
const personalList = await utils.Helpers.getAllBookmarks();
const deleted = personalList.find(b => b.url === testUrl);
if (!deleted) {
utils.Formatters.consoleResult('Test 5', 'FAIL', 'Delete propagated (same bookmark)');
console.log(' → Deleting via one key deletes all');
return { pass: false, propagated: true, sameBookmark };
} else {
utils.Formatters.consoleResult('Test 5', 'PASS', 'Delete did not propagate (separate bookmarks)');
console.log(' → Each bookmark exists independently');
console.log(' → Can delete via specific API key');
return { pass: true, propagated: false, sameBookmark };
}
} catch (error) {
utils.Formatters.consoleResult('Test 5', 'FAIL', error.message);
throw error;
}
}
// Test 6: Delete with Same User Different Keys
async function test6_DeleteSameUserDifferentKeys() {
console.log('\n=== Test 6: Delete - Same User, Different Keys ===');
console.log('Purpose: Verify delete behavior when same user, different API keys');
try {
utils.SessionManager.setContext(
CONFIG.serverUrl,
CONFIG.workApiKey,
CONFIG.workUser,
CONFIG.workBundle
);
const testUrl = 'https://delete-same-user.example.com';
// Create with first key
const bm1 = await utils.Helpers.createBookmark(testUrl, {
title: 'Same User Delete Test - Key 1'
});
console.log(` Created with Key 1: ID=${bm1.id}`);
// Create same URL with second key (personal)
utils.SessionManager.setContext(
CONFIG.serverUrl,
CONFIG.personalApiKey,
CONFIG.personalUser,
CONFIG.personalBundle
);
const bm2 = await utils.Helpers.createBookmark(testUrl, {
title: 'Same User Delete Test - Key 2'
});
console.log(` Created with Key 2: ID=${bm2.id}`);
// Check if same bookmark
if (bm1.id === bm2.id) {
utils.Formatters.consoleResult('Test 6', 'WARN', 'Same bookmark - delete propagates');
console.log(' → Same user means same bookmark');
console.log(' → Deleting via either key removes it');
// Delete via work
await utils.Helpers.deleteBookmark(bm1.id);
// Verify gone
utils.SessionManager.setContext(
CONFIG.serverUrl,
CONFIG.workApiKey,
CONFIG.workUser,
CONFIG.workBundle
);
const workList = await utils.Helpers.getAllBookmarks();
const exists = workList.find(b => b.url === testUrl);
if (!exists) {
console.log(' → Verified: bookmark deleted via both keys');
}
return { pass: true, same: true, propagates: true };
} else {
utils.Formatters.consoleResult('Test 6', 'PASS', 'Different bookmarks - delete is isolated');
console.log(' → Different API keys create different bookmarks');
console.log(' → Can delete independently');
// Delete via work
await utils.Helpers.deleteBookmark(bm1.id);
// Verify work side gone
const workList = await utils.Helpers.getAllBookmarks();
const workGone = !workList.find(b => b.url === testUrl);
// Verify personal still exists
utils.SessionManager.setContext(
CONFIG.serverUrl,
CONFIG.personalApiKey,
CONFIG.personalUser,
CONFIG.personalBundle
);
const personalList = await utils.Helpers.getAllBookmarks();
const personalExists = personalList.find(b => b.url === testUrl);
if (workGone && personalExists) {
console.log(' → Verified: work deleted, personal still exists');
}
return { pass: true, same: false, propagates: false };
}
} catch (error) {
utils.Formatters.consoleResult('Test 6', 'FAIL', error.message);
throw error;
}
}
// Run all tests
async function runDeletionTests() {
console.log('\n' + '='.repeat(60));
console.log(' ' + SCENARIO_NAME);
console.log('='.repeat(60));
const results = [];
try {
results[0] = await test5_DeletePropagation();
results[1] = await test6_DeleteSameUserDifferentKeys();
} catch (error) {
console.error('Test suite error:', error.message);
utils.Helpers.resetBookmarks();
}
console.log('\n' + '='.repeat(60));
console.log(' Deletion Tests Complete');
console.log('='.repeat(60));
return results;
}
// Export
window.LinkdingSyncTests.TestDeletion = {
run: runDeletionTests,
test5: test5_DeletePropagation,
test6: test6_DeleteSameUserDifferentKeys
};

View File

@@ -0,0 +1,176 @@
/*
* Test Module: API Key & User Isolation
* Tests scenarios 1 and 2
*/
'use strict';
const utils = require('../utils.js').LinkdingSyncTests;
const SCENARIO_NAME = 'API Key & User Isolation Tests';
// Helper to create a test bookmark with work API key
async function createWorkBookmark(url, options) {
utils.SessionManager.setContext(
CONFIG.serverUrl,
CONFIG.workApiKey,
CONFIG.workUser,
CONFIG.workBundle
);
return utils.Helpers.createBookmark(url, options);
}
// Helper to create a test bookmark with personal API key
async function createPersonalBookmark(url, options) {
utils.SessionManager.setContext(
CONFIG.serverUrl,
CONFIG.personalApiKey,
CONFIG.personalUser,
CONFIG.personalBundle
);
return utils.Helpers.createBookmark(url, options);
}
// Helper to fetch with work API key
async function fetchWork(id) {
utils.SessionManager.setContext(
CONFIG.serverUrl,
CONFIG.workApiKey,
CONFIG.workUser,
CONFIG.workBundle
);
return utils.Helpers.fetchBookmark(id);
}
// Helper to fetch with personal API key
async function fetchPersonal(id) {
utils.SessionManager.setContext(
CONFIG.serverUrl,
CONFIG.personalApiKey,
CONFIG.personalUser,
CONFIG.personalBundle
);
return utils.Helpers.fetchBookmark(id);
}
// Helper to list with personal API key
async function listPersonal(queryParams = {}) {
utils.SessionManager.setContext(
CONFIG.serverUrl,
CONFIG.personalApiKey,
CONFIG.personalUser,
CONFIG.personalBundle
);
return utils.SessionManager.call('/api/bookmarks/', 'GET', queryParams);
}
// Test 1: Same URL, Different API Keys, Same User
async function test1_SameUserDifferentKeys() {
console.log('\n=== Test 1: Same URL, Different API Keys, Same User ===');
console.log('Purpose: Verify if API keys provide isolation within same user');
try {
// Create with work key
const bm1 = await createWorkBookmark('https://isolation-test.example.com', {
title: 'Isolation Test - Work Key'
});
// Create same URL with personal key
const bm2 = await createPersonalBookmark('https://isolation-test.example.com', {
title: 'Isolation Test - Personal Key'
});
console.log(` Work bookmark ID: ${bm1.id}`);
console.log(` Personal bookmark ID: ${bm2.id}`);
if (bm1.id === bm2.id) {
utils.Formatters.consoleResult('Test 1', 'FAIL', 'Same bookmark ID - API keys do NOT provide isolation');
console.log(' → Same user means same bookmarks regardless of API key');
return { pass: false, reason: 'API keys do not provide isolation within same user' };
} else {
utils.Formatters.consoleResult('Test 1', 'PASS', 'Different bookmark IDs - API keys provide isolation');
console.log(' → Different API keys create separate bookmarks');
return { pass: true, ids: { work: bm1.id, personal: bm2.id } };
}
} catch (error) {
utils.Formatters.consoleResult('Test 1', 'FAIL', error.message);
throw error;
}
}
// Test 2: Different Users - Verify isolation
async function test2_DifferentUsers() {
console.log('\n=== Test 2: Different Users - Verify Isolation ===');
console.log('Purpose: Verify isolation between different users');
try {
// Create bookmark as work user
const workUrl = 'https://cross-user-isolation.example.com';
const workBookmark = await createWorkBookmark(workUrl, {
title: 'Cross-User Test - Work'
});
console.log(` Bookmark created by work user: ID=${workBookmark.id}`);
// Work user sees their own bookmark
const workFetch = await fetchWork(workBookmark.id);
console.log(` Work user sees bookmark: ${workFetch.title}`);
// Personal user queries for the test bookmark
utils.SessionManager.setContext(
CONFIG.serverUrl,
CONFIG.personalApiKey,
CONFIG.personalUser,
CONFIG.personalBundle
);
const personalFetch = await listPersonal({ limit: 100 });
console.log(` Personal user sees ${personalFetch.count || personalFetch.results?.length || 0} bookmarks`);
if (personalFetch.results && personalFetch.results.length > 0) {
utils.Formatters.consoleResult('Test 2', 'FAIL', 'Users can see each other\'s bookmarks');
console.log(' → Sharing enabled or same underlying user');
return { pass: false, reason: 'Users can see each other\'s bookmarks (sharing or same user)' };
} else {
utils.Formatters.consoleResult('Test 2', 'PASS', 'Proper user isolation exists');
console.log(' → Can use different API keys for isolation');
return { pass: true };
}
} catch (error) {
utils.Formatters.consoleResult('Test 2', 'FAIL', error.message);
throw error;
}
}
// Run all tests
async function runIsolationTests() {
console.log('\n' + '='.repeat(60));
console.log(' ' + SCENARIO_NAME);
console.log('='.repeat(60));
const results = [];
try {
results[0] = await test1_SameUserDifferentKeys();
results[1] = await test2_DifferentUsers();
} catch (error) {
console.error('Test suite error:', error.message);
utils.Helpers.resetBookmarks();
}
console.log('\n' + '='.repeat(60));
console.log(' Isolation Tests Complete');
console.log('='.repeat(60));
return results;
}
// Export
window.LinkdingSyncTests.TestIsolation = {
run: runIsolationTests,
test1: test1_SameUserDifferentKeys,
test2: test2_DifferentUsers
};

View File

@@ -0,0 +1,192 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<title>LinkdingSync Tests</title>
<style>body{font-family:monospace;padding:20px;}.log{white-space:pre-wrap;background:#1e1e1e;color:#d4d4d4;padding:10px;border-radius:4px;min-height:200px;}</style>
</head>
<body>
<h1>LinkdingSync Test Runner</h1>
<p>Paste your Linkding API keys below or use defaults</p>
<table>
<tr><td>Server URL:</td><td><input type="text" id="server" value="https://links.blabber1565.com" style="width:300px"></td></tr>
<tr><td>Work API Key:</td><td><input type="password" id="wkey" value="4108e3aff26fb82bf074f5d4dfa4757763520b06" style="width:300px"></td></tr>
<tr><td>Personal API Key:</td><td><input type="password" id="pkey" value="9b80accd3b9b4b91c2a7adc3dcf41621b025329a" style="width:300px"></td></tr>
</table>
<p><button id="run" style="margin-top:10px;">Run Tests</button></p>
<p><button id="cleanup" style="margin-top:5px;">Cleanup Test Bookmarks</button></p>
<p><button id="list" style="margin-top:5px;">List All Bookmarks</button></p>
<div class="log" id="log"></div>
<script>
(function() {
'use strict';
var log = document.getElementById('log');
var server = document.getElementById('server').value;
var wkey = document.getElementById('wkey').value;
var pkey = document.getElementById('pkey').value;
function logMsg(msg) {
log.innerHTML = msg + log.innerHTML;
}
function fetch(m, e, d) {
return fetch(server + e, { method: m, headers: { Authorization: 'Token ' + wkey, 'Content-Type': 'application/json' }, body: d ? JSON.stringify(d) : null })
.then(function(r) {
if (r.ok) return r.json();
if (r.status === 404) return { error: '404', status: r.status };
throw new Error(r.status + ': ' + r.statusText);
});
}
var results = [];
var ctx = { key: wkey };
// TEST 1
fetch('POST', '/api/bookmarks/', { url: 'https://t1.example.com', title: 'W1', notes: '{"test":1}' })
.then(function(b1) {
logMsg('T1: Work bookmark ID: ' + b1.id);
ctx.key = pkey;
return fetch('POST', '/api/bookmarks/', { url: 'https://t1.example.com', title: 'P1', notes: '{"test":1}' });
})
.then(function(b2) {
logMsg('T1: Personal bookmark ID: ' + b2.id);
logMsg('Same ID? ' + (b1.id === b2.id));
if (b1.id === b2.id) results.push({pass:false,reason:'API keys do NOT isolate'});
else results.push({pass:true,reason:'API keys provide isolation'});
logMsg('T1: ' + (results[results.length-1].pass ? '✓ PASS' : '✗ FAIL'));
logMsg(' ' + results[results.length-1].reason);
})
.then(function() {
// TEST 2
ctx.key = pkey;
return fetch('GET', '/api/bookmarks/?limit=100');
})
.then(function(d) {
logMsg('T2: Personal sees ' + d.count + ' bookmarks');
var myTests = d.results ? d.results.filter(function(b) { return b.testId || b.notes?.testId; }) : [];
logMsg('T2: My test bookmarks: ' + myTests.length);
if (myTests.length === 1) results.push({pass:true,reason:'User isolation works'});
else if (myTests.length > 1) results.push({pass:false,reason:'Personal sees multiple test bookmarks'});
else results.push({pass:null,reason:'Unexpected count'});
logMsg('T2: ' + (results[results.length-1].pass ? '✓ PASS' : (results[results.length-1].pass === false ? '✗ FAIL' : '⚠ WARN')));
})
.then(function() {
// TEST 3
ctx.key = wkey;
return fetch('POST', '/api/bookmarks/', { url: 'https://t3.example.com', title: 'W', path: 'W', notes: '{"test":3}' });
})
.then(function(b1) {
ctx.key = pkey;
return fetch('POST', '/api/bookmarks/', { url: 'https://t3.example.com', title: 'P', path: 'P', notes: '{"test":3}' });
})
.then(function(b2) {
logMsg('T3: Work ID: ' + b1.id + ' Personal ID: ' + b2.id);
logMsg('T3: Same? ' + (b1.id === b2.id));
if (b1.id === b2.id) results.push({pass:false,reason:'Server merges by URL'});
else results.push({pass:true,reason:'Server creates separate'});
logMsg('T3: ' + (results[results.length-1].pass ? '✓ PASS' : '✗ FAIL'));
})
.then(function() {
// TEST 4
ctx.key = wkey;
return fetch('POST', '/api/bookmarks/', { url: 'https://t4.example.com', title: 'Initial', notes: '{"test":4}' });
})
.then(function(bm) {
logMsg('T4: Initial ID: ' + bm.id);
return fetch('PUT', '/api/bookmarks/' + bm.id + '/', { title: 'Work Title', notes: '{"test":4}' });
})
.then(function() {
return fetch('GET', '/api/bookmarks/' + bm.id + '/');
})
.then(function(f) {
logMsg('T4: Final title: ' + f.title);
if (f.title === 'Work Title') results.push({pass:true,reason:'Title update works'});
else if (f.title === 'Initial') results.push({pass:true,reason:'Title NOT updated'});
else results.push({pass:null,reason:'Unknown title'});
logMsg('T4: ' + (results[results.length-1].pass ? '✓ PASS' : (results[results.length-1].pass === false ? '✗ FAIL' : '⚠ WARN')));
})
.then(function() {
// TEST 5
ctx.key = wkey;
return fetch('POST', '/api/bookmarks/', { url: 'https://t5.example.com', title: 'W', notes: '{"test":5}' });
})
.then(function(b1) {
ctx.key = pkey;
return fetch('POST', '/api/bookmarks/', { url: 'https://t5.example.com', title: 'P', notes: '{"test":5}' });
})
.then(function(b2) {
logMsg('T5: IDs: ' + b1.id + ' ' + b2.id);
ctx.key = wkey;
return fetch('DELETE', '/api/bookmarks/' + b1.id + '/');
})
.then(function() {
ctx.key = pkey;
return fetch('GET', '/api/bookmarks/?limit=100&url=https://t5.example.com');
})
.then(function(d) {
var cnt = d.count || 0;
logMsg('T5: Personal sees with URL: ' + cnt);
if (cnt === 0) results.push({pass:false,reason:'Delete propagated'});
else results.push({pass:true,reason:'Delete isolated'});
logMsg('T5: ' + (results[results.length-1].pass ? '✓ PASS' : '✗ FAIL'));
})
.then(function() {
// TEST 6
ctx.key = wkey;
return Promise.all([
fetch('POST', '/api/bookmarks/', { url: 'https://b6.1.example.com', title: 'B1', notes: '{"test":6}' }),
fetch('POST', '/api/bookmarks/', { url: 'https://b6.2.example.com', title: 'B2', notes: '{"test":6}' })
]);
})
.then(function() {
logMsg('T6: Created 2 W bookmarks');
ctx.key = wkey;
return fetch('GET', '/api/bookmarks/?all=work&limit=100');
})
.then(function(wd) {
ctx.key = pkey;
return fetch('GET', '/api/bookmarks/?all=personal&limit=100');
})
.then(function(pd) {
logMsg('T6: W bundle: ' + wd.count + ' Personal: ' + pd.count);
results.push({pass:true,reason:'Bundle filtering works'});
logMsg('T6: ✓ PASS');
})
.then(function() {
// SUMMARY
logMsg(''); logMsg('='.repeat(60)); logMsg(' Summary'); logMsg('='.repeat(60));
var passed = results.filter(function(r) { return r.pass === true; }).length;
var failed = results.filter(function(r) { return r.pass === false; }).length;
var warned = results.filter(function(r) { return r.pass === null; }).length;
logMsg(' Total: ' + results.length + ' Passed: ' + passed + ' Failed: ' + failed + ' Warn: ' + warned);
logMsg('='.repeat(60));
});
document.getElementById('run').addEventListener('click', function() {
log.innerHTML = '';
ctx.key = wkey;
});
document.getElementById('cleanup').addEventListener('click', function() {
fetch('GET', '/api/bookmarks/?limit=100').then(function(d) {
var tests = d.results ? d.results.filter(function(b) { return b.testId || b.notes?.testId; }) : [];
logMsg(''); logMsg('[Cleanup] ' + tests.length + ' test bookmarks');
if (tests.length) {
Promise.all(tests.map(function(t) { return fetch('DELETE', '/api/bookmarks/' + t.id + '/'); })).then(function() { logMsg('[Cleanup] Done'); });
}
});
});
document.getElementById('list').addEventListener('click', function() {
fetch('GET', '/api/bookmarks/?limit=100').then(function(d) {
logMsg(''); logMsg('All bookmarks: ' + d.count);
if (d.results) {
d.results.forEach(function(b) { logMsg(' ' + b.url + ' [' + b.title + ']'); });
}
});
});
})();
</script>
</body>
</html>

View File

@@ -0,0 +1,201 @@
/*
* LinkdingSync Test Utilities
* Shared functions for test modules
*/
'use strict';
// ====================================================================
// CONFIGURATION
// ====================================================================
const CONFIG = {
serverUrl: 'https://links.blabber1565.com',
workApiKey: '4108e3aff26fb82bf074f5d4dfa4757763520b06',
workUser: 'linkdingsync_tester',
workBundle: 'work',
personalApiKey: '9b80accd3b9b4b91c2a7adc3dcf41621b025329a',
personalUser: 'linkdingsync_tester_2',
personalBundle: 'personal',
cleanupAfterTests: true
};
// ====================================================================
// SESSION MANAGEMENT
// ====================================================================
const SessionManager = {
currentContext: null,
setContext(serverUrl, apiKey, userId, bundle) {
this.currentContext = {
serverUrl: serverUrl.endsWith('/') ? serverUrl : serverUrl + '/',
apiKey,
userId,
bundle
};
return this;
},
getHeaders() {
return {
'Authorization': `Token ${this.currentContext.apiKey}`,
'Content-Type': 'application/json'
};
},
async call(endpoint, method = 'GET', queryParams = {}) {
const url = new URL(endpoint, this.currentContext.serverUrl);
Object.entries(queryParams).forEach(([key, value]) => {
url.searchParams.append(key, value);
});
const response = await fetch(url, {
method,
headers: this.getHeaders(),
body: null
});
if (!response.ok) {
const text = await response.text().slice(0, 200);
throw new Error(`${response.status}: ${response.statusText} - ${text}`);
}
return await response.json();
}
};
// ====================================================================
// HELPERS
// ====================================================================
const Helpers = {
generateTestId(prefix = 'test') {
return `${prefix}-${Date.now().toString().slice(-4)}-${Math.random().toString(36).substring(2, 4)}`;
},
async createBookmark(url, options = {}) {
const testId = this.generateTestId();
const baseUrl = new URL(url);
baseUrl.hostname = `${testId}.${baseUrl.hostname}`;
const bookmarkData = {
url: baseUrl.href,
title: options.title || `Test: ${testId}`,
description: options.description || 'Test bookmark',
notes: JSON.stringify({
path: options.path || `Test/${testId}`,
userNotes: options.notes || 'Test bookmark',
testId
})
};
const response = await SessionManager.call('/api/bookmarks/', 'POST', null, {});
console.log(` Created: ID=${response.id}`);
return response;
},
async updateBookmark(bookmarkId, data) {
const response = await SessionManager.call(`/api/bookmarks/${bookmarkId}/`, 'PUT', null, {});
console.log(` Updated: ID=${bookmarkId}`);
return response;
},
async deleteBookmark(bookmarkId) {
await SessionManager.call(`/api/bookmarks/${bookmarkId}/`, 'DELETE', {});
console.log(` Deleted: ID=${bookmarkId}`);
return true;
},
async fetchBookmark(id) {
return SessionManager.call(`/api/bookmarks/${id}/`);
},
parseNotes(noteString) {
if (!noteString) return null;
try {
const parsed = JSON.parse(noteString);
return parsed;
} catch {
return { userNotes: noteString, version: '1.0', path: '', autoTags: [], bundleTag: null };
}
},
async getAllBookmarks() {
let bookmarks = [];
let offset = 0;
const batchSize = 100;
do {
const response = await SessionManager.call('/api/bookmarks/', 'GET', { limit: batchSize, offset });
bookmarks.push(...(response.results || []));
offset += batchSize;
} while (bookmarks.length > offset);
return bookmarks;
},
// Reset all bookmarks to clean state
async resetBookmarks() {
console.log('[Utils] Resetting all bookmarks...');
try {
const allBookmarks = await this.getAllBookmarks();
const testBookmarks = allBookmarks.filter(b => b.testId);
if (testBookmarks.length > 0) {
console.log(`[Utils] Found ${testBookmarks.length} test bookmarks to delete`);
for (const bm of testBookmarks) {
await this.deleteBookmark(bm.id);
}
console.log('[Utils] Reset complete');
} else {
console.log('[Utils] No test bookmarks found');
}
} catch (error) {
console.error('[Utils] Reset failed:', error.message);
throw error;
}
}
};
// ====================================================================
// FORMATTERS
// ====================================================================
const Formatters = {
formatTimestamp(timestamp) {
if (!timestamp) return 'Never';
return new Date(timestamp).toLocaleString('en-US', {
year: 'numeric', month: 'short', day: 'numeric',
hour: '2-digit', minute: '2-digit'
});
},
consoleHeader(text) {
console.log(''.padEnd(60, '='));
console.log(` ${text}`.padEnd(60, '='.charCodeAt(0) === text.charCodeAt(0) ? '=' : '-').padEnd(60, '='));
console.log(''.padEnd(60, '='));
},
consoleResult(scenario, status, details = '') {
const icon = status === 'PASS' ? '✓' : status === 'FAIL' ? '✗' : '⚠';
const emoji = status === 'PASS' ? '✅' : status === 'FAIL' ? '❌' : '⚠️';
console.log(` [${scenario}] ${icon} ${emoji} ${status}`);
if (details) console.log(` ${details}`);
}
};
// ====================================================================
// EXPORT
// ====================================================================
window.LinkdingSyncTests = {
CONFIG,
SessionManager,
Helpers,
Formatters,
consoleHeader: Formatters.consoleHeader,
consoleResult: Formatters.consoleResult
};

View File

@@ -0,0 +1,217 @@
/*
* LinkdingSync Verified Test Runner
* Includes verification and cleanup between tests
*/
'use strict';
(function(w) {
'use strict';
var window = w || window;
var serverUrl = 'https://links.blabber1565.com';
var workApiKey = '4108e3aff26fb82bf074f5d4dfa4757763520b06';
var personalApiKey = '9b80accd3b9b4b91c2a7adc3dcf41621b025329a';
var workUser = 'linkdingsync_tester';
var personalUser = 'linkdingsync_tester_2';
var state = { url: '', apiKey: '', userId: null, results: [] };
function call(method, endpoint, data) {
var u = new URL(endpoint, state.url);
var r = fetch(u, {
method: method,
headers: { 'Authorization': 'Token ' + state.apiKey, 'Content-Type': 'application/json' },
body: data ? JSON.stringify(data) : null
});
return r.then(function(res) {
if (!res.ok) {
if (res.status === 404) return { error: '404', status: res.status };
throw new Error(res.status + ': ' + res.statusText);
}
return res.json();
});
}
function createBookmark(url, opts) {
var testId = 'test-' + Date.now().toString().slice(-4) + '-' + Math.random().toString(36).slice(2, 4);
var base = new URL(url);
base.hostname = testId + '.' + base.hostname;
var data = { url: base.href, title: opts.title || 'Test: ' + testId, description: 'Test', notes: JSON.stringify({ testId, path: 'Test/' + testId }) };
console.log(' Created: ' + data.url);
return call('POST', '/api/bookmarks/', data);
}
function listBookmarks() {
return call('GET', '/api/bookmarks/?limit=100');
}
function cleanup() {
return listBookmarks().then(function(data) {
var tests = data.results.filter(function(b) { return b.testId; });
console.log('[Cleanup] Found ' + tests.length + ' test bookmarks to delete');
if (tests.length > 0) {
return Promise.all(tests.map(function(t) { return call('DELETE', '/api/bookmarks/' + t.id + '/'); }));
}
}).then(function() { console.log('[Cleanup] Done'); });
}
// TEST 1
function test1() {
console.log('\n=== TEST 1: API Key Isolation ===');
return verify().then(function() {
state.url = serverUrl; state.apiKey = workApiKey; state.userId = workUser;
return createBookmark('https://t1.example.com', { title: 'T1-Work' });
}).then(function(b1) {
state.url = serverUrl; state.apiKey = personalApiKey; state.userId = personalUser;
return createBookmark('https://t1.example.com', { title: 'T1-Personal' });
}).then(function(b2) {
console.log(' Work ID: ' + b1.id + ' Personal ID: ' + b2.id + ' Same? ' + (b1.id === b2.id));
if (b1.id === b2.id) { console.log(' [TEST 1] ✗ FAIL - API keys do NOT provide isolation'); return { pass: false, reason: 'API keys do not provide isolation' }; }
else { console.log(' [TEST 1] ✓ PASS - API keys provide isolation'); return { pass: true, reason: 'API keys provide isolation' }; }
});
}
// TEST 2
function test2() {
console.log('\n=== TEST 2: Cross-User Visibility ===');
return test1().then(function(r1) {
state.url = serverUrl; state.apiKey = personalApiKey; state.userId = personalUser;
return listBookmarks();
}).then(function(data) {
var testCount = 0;
if (data.results && Array.isArray(data.results)) {
testCount = data.results.filter(function(b) { return b.url && b.url.indexOf('test-') === -1; }).length;
}
console.log(' Personal sees ' + testCount + ' non-test bookmarks');
var hasWorkBookmark = data.results && Array.isArray(data.results) && data.results.some(function(b) { return b.url === 'https://t2.example.com'; });
if (hasWorkBookmark) { console.log(' [TEST 2] ✗ FAIL - Personal can see work\'s bookmark'); return { pass: false, reason: 'Cross-user visibility' }; }
else { console.log(' [TEST 2] ✓ PASS - Proper user isolation'); return { pass: true, reason: 'Proper user isolation' }; }
});
}
// TEST 3
function test3() {
console.log('\n=== TEST 3: Conflict Resolution ===');
return test2().then(function(r2) {
state.url = serverUrl; state.apiKey = workApiKey; state.userId = workUser;
return createBookmark('https://t3.example.com', { title: 'T3-Work', path: 'Work' });
}).then(function(b1) {
state.url = serverUrl; state.apiKey = personalApiKey; state.userId = personalUser;
return createBookmark('https://t3.example.com', { title: 'T3-Personal', path: 'Personal' });
}).then(function(b2) {
console.log(' Work ID: ' + b1.id + ' Personal ID: ' + b2.id + ' Same? ' + (b1.id === b2.id));
if (b1.id === b2.id) { console.log(' [TEST 3] ✗ FAIL - Server merges by URL'); return { pass: false, reason: 'Server merges by URL' }; }
else { console.log(' [TEST 3] ✓ PASS - Separate bookmarks'); return { pass: true, reason: 'Separate bookmarks' }; }
});
}
// TEST 4
function test4() {
console.log('\n=== TEST 4: Field Update Behavior ===');
return test3().then(function(r3) {
state.url = serverUrl; state.apiKey = workApiKey; state.userId = workUser;
return createBookmark('https://t4.example.com', { title: 'Initial', path: 'Initial' });
}).then(function(bm) {
console.log(' Initial ID: ' + bm.id);
return call('PUT', '/api/bookmarks/' + bm.id + '/', { title: 'Work Title', description: 'Work', notes: JSON.stringify({ path: 'Work', userNotes: 'Work' }) });
}).then(function(resp) {
console.log(' Work update: ' + (resp.error ? resp.error : 'OK'));
return call('GET', '/api/bookmarks/' + bm.id + '/');
}).then(function(final) {
console.log(' Final title: ' + final.title);
if (final.title === 'Work Title') { console.log(' [TEST 4] ✓ Title updated'); return { pass: true, strategy: 'title-update', title: final.title }; }
else if (final.title === 'Initial') { console.log(' [TEST 4] ✓ Title NOT updated (notes only)'); return { pass: true, strategy: 'notes-only', title: final.title }; }
else { console.log(' [TEST 4] ? UNKNOWN title: ' + final.title); return { pass: null, strategy: 'unknown', title: final.title }; }
});
}
// TEST 5
function test5() {
console.log('\n=== TEST 5: Delete Behavior ===');
return test4().then(function(r4) {
state.url = serverUrl; state.apiKey = workApiKey; state.userId = workUser;
return createBookmark('https://t5.example.com', { title: 'T5-Work', path: 'Work' });
}).then(function(b1) {
state.url = serverUrl; state.apiKey = personalApiKey; state.userId = personalUser;
return createBookmark('https://t5.example.com', { title: 'T5-Personal', path: 'Personal' });
}).then(function(b2) {
console.log(' Work ID: ' + b1.id + ' Personal ID: ' + b2.id + ' Same? ' + (b1.id === b2.id));
state.url = serverUrl; state.apiKey = workApiKey; state.userId = workUser;
return call('DELETE', '/api/bookmarks/' + b1.id + '/');
}).then(function() {
state.url = serverUrl; state.apiKey = personalApiKey; state.userId = personalUser;
return call('GET', '/api/bookmarks/?limit=100&url=https://t5.example.com');
}).then(function(data) {
console.log(' Personal sees ' + (data.count || 0) + ' with that URL');
if (data.count === 0 || !data.results) { console.log(' [TEST 5] ✗ FAIL - Delete propagated'); return { pass: false, reason: 'Delete propagated' }; }
else { console.log(' [TEST 5] ✓ PASS - Delete isolated'); return { pass: true, reason: 'Delete isolated' }; }
});
}
// TEST 6
function test6() {
console.log('\n=== TEST 6: Bundle Filtering ===');
return test5().then(function(r5) {
state.url = serverUrl; state.apiKey = workApiKey; state.userId = workUser;
return Promise.all([createBookmark('https://b6-1.example.com', { title: 'B6-W1', path: 'W1' }), createBookmark('https://b6-2.example.com', { title: 'B6-W2', path: 'W2' })]);
}).then(function() {
console.log(' Created 2 work bookmarks');
state.url = serverUrl; state.apiKey = workApiKey; state.userId = workUser;
return call('GET', '/api/bookmarks/?all=work&limit=100');
}).then(function(data) {
var wc = data.count || (data.results ? data.results.length : 0);
console.log(' Work bundle: ' + wc + ' bookmarks');
state.url = serverUrl; state.apiKey = personalApiKey; state.userId = personalUser;
return call('GET', '/api/bookmarks/?all=personal&limit=100');
}).then(function(data) {
var pc = data.count || (data.results ? data.results.length : 0);
console.log(' Personal bundle: ' + pc + ' bookmarks');
if (wc > 0) { console.log(' [TEST 6] ✓ PASS - Bundle filtering works'); return { pass: true, work: wc, personal: pc }; }
else { console.log(' [TEST 6] ? WARN - Bundle filtering unclear'); return { pass: null, reason: 'Bundle unclear' }; }
});
}
function verify() {
return listBookmarks().then(function(data) {
var tests = data.results.filter(function(b) { return b.testId; });
console.log('[Verify] Test bookmarks: ' + tests.length);
return tests.length === 0;
});
}
// MAIN
(function main() {
console.log(''); console.log('LinkdingSync Verified Test Runner loaded'); console.log('');
console.log('Running tests...'); console.log('');
test1().then(function(r1) { consoleLog(r1); return r1; });
test2().then(function(r2) { consoleLog(r2); return r2; });
test3().then(function(r3) { consoleLog(r3); return r3; });
test4().then(function(r4) { consoleLog(r4); return r4; });
test5().then(function(r5) { consoleLog(r5); return r5; });
test6().then(function(r6) {
console.log(''); console.log('='.repeat(60)); console.log(' Summary'); console.log('='.repeat(60));
var passed = 0, failed = 0, warned = 0;
[r1, r2, r3, r4, r5, r6].forEach(function(r) {
if (r.pass === true) passed++; else if (r.pass === false) failed++; else warned++;
});
console.log(' Total: 6, Passed: ' + passed + ', Failed: ' + failed + ', Warning: ' + warned);
console.log('='.repeat(60));
return cleanup().then(function() {
console.log(''); console.log('LinkdingSyncTests available:'); console.log(' cleanup()'); console.log(' listAll()'); console.log(''); console.log('Done!');
return { r1, r2, r3, r4, r5, r6, passed, failed, warned };
});
});
})();
function consoleLog(r) {
if (r.pass === true) console.log(' [PASS]');
else if (r.pass === false) console.log(' [FAIL]');
else console.log(' [WARN]');
console.log(' ' + r.reason);
}
window.LinkdingSyncTests = { cleanup: cleanup, listAll: listBookmarks, verify: verify };
})(window);
console.log(''); console.log('LinkdingSync Verified Test Runner loaded');