Initial commit: LinkSyncServer and LinkSyncExtension projects with complete documentation, models, API endpoints, tests, and extension implementation
This commit is contained in:
@@ -0,0 +1,628 @@
|
||||
# Phase 0: Server Behavior Validation Tests
|
||||
|
||||
## Overview
|
||||
|
||||
This document outlines the Phase 0 testing plan to validate Linkding server behavior regarding bookmark isolation, conflict resolution, and API authentication. These tests will inform the final architecture and conflict resolution strategy.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
### Test Environment Setup
|
||||
|
||||
You need to create the following on your Linkding instance:
|
||||
|
||||
1. **Two User Accounts**:
|
||||
- `user_work` - For work-related bookmarks
|
||||
- `user_personal` - For personal bookmarks
|
||||
|
||||
2. **API Keys per User**:
|
||||
- Each user needs at least 2 API keys (one for each bundle)
|
||||
- Example: `user_work_key_1`, `user_work_key_2`, `user_personal_key_1`, `user_personal_key_2`
|
||||
|
||||
3. **Two Bundles per User**:
|
||||
- `work` bundle (for work bookmarks)
|
||||
- `personal` bundle (for personal bookmarks)
|
||||
|
||||
### Firefox Test Harness
|
||||
|
||||
We'll create a Firefox extension (or use existing Linkding extension) that:
|
||||
- Allows testing with multiple API keys
|
||||
- Can switch between work/personal contexts
|
||||
- Runs tests via DevTools console or automated scripts
|
||||
|
||||
---
|
||||
|
||||
## Test Scenarios
|
||||
|
||||
### Scenario 1: Same URL, Different API Keys, Same User
|
||||
|
||||
**Purpose**: Verify if API keys provide bookmark isolation within the same user.
|
||||
|
||||
**Setup**:
|
||||
1. Create bookmark via `user_work_key_1` with URL: `https://example.com`
|
||||
2. Create same bookmark via `user_work_key_2` with same URL
|
||||
|
||||
**Expected Behavior**:
|
||||
- If API keys only authenticate the user, both calls create/update the same bookmark
|
||||
- API keys don't provide isolation - only user matters
|
||||
|
||||
**Test Command**:
|
||||
```javascript
|
||||
// Test 1: Same user, different API keys
|
||||
async function testSameUserDifferentKeys(serverUrl, apiKey1, apiKey2) {
|
||||
try {
|
||||
// Create with first key
|
||||
const response1 = await fetch(`${serverUrl}api/bookmarks/`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Authorization': `Token ${apiKey1}`,
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify({
|
||||
url: 'https://example.com',
|
||||
title: 'Test Bookmark 1',
|
||||
description: 'First key',
|
||||
notes: '{"path": "Work/Dev1", "userNotes": "Key 1"}'
|
||||
})
|
||||
});
|
||||
const bookmark1 = await response1.json();
|
||||
console.log('Bookmark 1:', bookmark1);
|
||||
|
||||
// Try creating same URL with second key
|
||||
const response2 = await fetch(`${serverUrl}api/bookmarks/`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Authorization': `Token ${apiKey2}`,
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify({
|
||||
url: 'https://example.com',
|
||||
title: 'Test Bookmark 2',
|
||||
description: 'Second key',
|
||||
notes: '{"path": "Work/Dev2", "userNotes": "Key 2"}'
|
||||
})
|
||||
});
|
||||
const bookmark2 = await response2.json();
|
||||
console.log('Bookmark 2:', bookmark2);
|
||||
|
||||
// Check if they're the same bookmark (same ID)
|
||||
if (bookmark1.id === bookmark2.id) {
|
||||
console.log('RESULT: Same bookmark (API keys do not isolate)');
|
||||
} else {
|
||||
console.log('RESULT: Different bookmarks (API keys provide isolation)');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error:', error.message);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Scenario 2: Same URL, Different Users
|
||||
|
||||
**Purpose**: Verify if different users can see each other's bookmarks without sharing enabled.
|
||||
|
||||
**Setup**:
|
||||
1. User A creates bookmark via their API key
|
||||
2. User B tries to retrieve same URL via their API key
|
||||
|
||||
**Expected Behavior**:
|
||||
- Without sharing: User B should get 404 or empty results
|
||||
- With sharing enabled: Both can see each other's bookmarks
|
||||
|
||||
**Test Command**:
|
||||
```javascript
|
||||
// Test 2: Different users, no sharing
|
||||
async function testDifferentUsersNoSharing(serverUrl, userAKey, userBKey) {
|
||||
try {
|
||||
// Create bookmark as User A
|
||||
const createResponse = await fetch(`${serverUrl}api/bookmarks/`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Authorization': `Token ${userAKey}`,
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify({
|
||||
url: 'https://shared-example.com',
|
||||
title: 'Shared Bookmark',
|
||||
description: 'Created by User A',
|
||||
notes: '{"path": "UserA/Shared"}'
|
||||
})
|
||||
});
|
||||
const bookmark = await createResponse.json();
|
||||
console.log('Bookmark created by User A:', bookmark.id);
|
||||
|
||||
// User B tries to get this bookmark
|
||||
const getResponse = await fetch(`${serverUrl}api/bookmarks/?all=Shared`, {
|
||||
headers: {
|
||||
'Authorization': `Token ${userBKey}`,
|
||||
}
|
||||
});
|
||||
|
||||
if (getResponse.ok) {
|
||||
const result = await getResponse.json();
|
||||
console.log(`User B sees ${result.count} bookmarks`);
|
||||
if (result.results && result.results.length > 0) {
|
||||
console.log('RESULT: User B can see User A\'s bookmark (sharing or same user)');
|
||||
} else {
|
||||
console.log('RESULT: User B cannot see User A\'s bookmark (proper isolation)');
|
||||
}
|
||||
} else {
|
||||
console.log('RESULT: User B cannot access (4xx/5xx error)');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error:', error.message);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Scenario 3: Conflict Resolution - Different Folders/Paths
|
||||
|
||||
**Purpose**: Determine how the server handles same URL in different paths.
|
||||
|
||||
**Setup**:
|
||||
1. Create bookmark as "Work" with path "Work/Development"
|
||||
2. Create same URL as "Personal" with path "Personal/Notes"
|
||||
|
||||
**Expected Behavior**:
|
||||
- Need to confirm if server creates duplicate or updates existing
|
||||
|
||||
**Test Command**:
|
||||
```javascript
|
||||
// Test 3: Conflict resolution with different paths
|
||||
async function testConflictResolution(serverUrl, workKey, personalKey) {
|
||||
const url = 'https://conflict-test.example.com';
|
||||
const pathWork = 'Work/Development';
|
||||
const pathPersonal = 'Personal/Notes';
|
||||
|
||||
try {
|
||||
// Create as Work
|
||||
const workBookmark = await createBookmark(serverUrl, workKey, url, {
|
||||
title: 'Conflict Test',
|
||||
description: 'Work version',
|
||||
notes: JSON.stringify({
|
||||
path: pathWork,
|
||||
userNotes: 'Work Development Notes',
|
||||
autoTags: [{name: 'Work'}]
|
||||
})
|
||||
});
|
||||
console.log('Work bookmark created:', workBookmark.id);
|
||||
|
||||
// Create same URL as Personal
|
||||
const personalBookmark = await createBookmark(serverUrl, personalKey, url, {
|
||||
title: 'Conflict Test',
|
||||
description: 'Personal version',
|
||||
notes: JSON.stringify({
|
||||
path: pathPersonal,
|
||||
userNotes: 'Personal Notes',
|
||||
autoTags: [{name: 'Personal'}]
|
||||
})
|
||||
});
|
||||
console.log('Personal bookmark created:', personalBookmark.id);
|
||||
|
||||
// Check IDs
|
||||
if (workBookmark.id === personalBookmark.id) {
|
||||
console.log('RESULT: Same bookmark ID - paths are merged or overwritten');
|
||||
// Fetch and show current state
|
||||
const fetchResponse = await fetch(`${serverUrl}api/bookmarks/${workBookmark.id}/`, {
|
||||
headers: {
|
||||
'Authorization': `Token ${workKey}`,
|
||||
}
|
||||
});
|
||||
const current = await fetchResponse.json();
|
||||
console.log('Current bookmark state:', current);
|
||||
} else {
|
||||
console.log('RESULT: Different bookmark IDs - server creates duplicates');
|
||||
console.log('Work ID:', workBookmark.id);
|
||||
console.log('Personal ID:', personalBookmark.id);
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error:', error.message);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Scenario 4: Title/Description Conflict
|
||||
|
||||
**Purpose**: Understand conflict resolution for title/description fields.
|
||||
|
||||
**Setup**:
|
||||
1. Update bookmark via Work context (title: "Work Title")
|
||||
2. Update same bookmark via Personal context (title: "Personal Title")
|
||||
3. Check final state
|
||||
|
||||
**Expected Behavior**:
|
||||
- Confirm last-write-wins or other resolution strategy
|
||||
|
||||
**Test Command**:
|
||||
```javascript
|
||||
// Test 4: Title/Description conflict resolution
|
||||
async function testTitleConflict(serverUrl, workKey, personalKey) {
|
||||
const url = 'https://title-conflict.example.com';
|
||||
|
||||
try {
|
||||
// Create initial bookmark
|
||||
const bookmark = await createBookmark(serverUrl, workKey, url, {
|
||||
title: 'Initial Title',
|
||||
description: 'Initial Description',
|
||||
notes: JSON.stringify({
|
||||
path: 'Work/Initial',
|
||||
userNotes: 'Initial'
|
||||
})
|
||||
});
|
||||
|
||||
// Update via Work
|
||||
await updateBookmark(serverUrl, workKey, 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
|
||||
await updateBookmark(serverUrl, personalKey, 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 fetchBookmark(serverUrl, workKey, bookmark.id);
|
||||
console.log('Final state:', {
|
||||
title: final.title,
|
||||
description: final.description,
|
||||
notes: JSON.parse(final.notes)
|
||||
});
|
||||
|
||||
if (final.title === 'Personal Title') {
|
||||
console.log('RESULT: Last-write-wins (Personal update took precedence)');
|
||||
} else if (final.title === 'Work Title') {
|
||||
console.log('RESULT: Last-write-wins (Work update took precedence)');
|
||||
} else if (final.title.includes('Work') && final.title.includes('Personal')) {
|
||||
console.log('RESULT: Merged title');
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error:', error.message);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Scenario 5: Delete Propagation
|
||||
|
||||
**Purpose**: Confirm if deleting a bookmark affects all API keys.
|
||||
|
||||
**Setup**:
|
||||
1. Create bookmark via multiple API keys (same URL)
|
||||
2. Delete via one API key
|
||||
3. Check if bookmark still exists via other keys
|
||||
|
||||
**Expected Behavior**:
|
||||
- If API keys create same bookmark, deletion should propagate
|
||||
- If API keys create separate bookmarks, deletion only affects one
|
||||
|
||||
**Test Command**:
|
||||
```javascript
|
||||
// Test 5: Delete propagation
|
||||
async function testDeletePropagation(serverUrl, workKey, personalKey) {
|
||||
const url = 'https://delete-test.example.com';
|
||||
|
||||
try {
|
||||
// Create via both keys
|
||||
const workBookmark = await createBookmark(serverUrl, workKey, url, {
|
||||
title: 'Delete Test',
|
||||
notes: JSON.stringify({path: 'Work/Dev'})
|
||||
});
|
||||
console.log('Work bookmark ID:', workBookmark.id);
|
||||
|
||||
const personalBookmark = await createBookmark(serverUrl, personalKey, url, {
|
||||
title: 'Delete Test',
|
||||
notes: JSON.stringify({path: 'Personal/Notes'})
|
||||
});
|
||||
console.log('Personal bookmark ID:', personalBookmark.id);
|
||||
|
||||
// Check if same bookmark
|
||||
const sameBookmark = workBookmark.id === personalBookmark.id;
|
||||
|
||||
// Delete via Work key
|
||||
const deleteResponse = await fetch(`${serverUrl}api/bookmarks/${workBookmark.id}/`, {
|
||||
method: 'DELETE',
|
||||
headers: {
|
||||
'Authorization': `Token ${workKey}`,
|
||||
}
|
||||
});
|
||||
|
||||
console.log('Delete response status:', deleteResponse.status);
|
||||
|
||||
// Try to fetch via Personal key
|
||||
const fetchPersonal = await fetch(`${serverUrl}api/bookmarks/?limit=100`, {
|
||||
headers: {
|
||||
'Authorization': `Token ${personalKey}`,
|
||||
}
|
||||
});
|
||||
|
||||
if (fetchPersonal.ok) {
|
||||
const personalResults = await fetchPersonal.json();
|
||||
const deleted = personalResults.results.find(b => b.url === url);
|
||||
|
||||
if (!deleted) {
|
||||
console.log('RESULT: Delete propagated (same bookmark)');
|
||||
} else {
|
||||
console.log('RESULT: Delete did not propagate (separate bookmarks)');
|
||||
}
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error:', error.message);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Scenario 6: Bundle Tag Isolation
|
||||
|
||||
**Purpose**: Verify if different bundle tags properly isolate bookmarks.
|
||||
|
||||
**Setup**:
|
||||
1. Create bookmark with bundle tag "work-bundle"
|
||||
2. Create bookmark with bundle tag "personal-bundle"
|
||||
3. Query each bundle separately
|
||||
|
||||
**Expected Behavior**:
|
||||
- Bundle tags should act as filters
|
||||
- Need to confirm if tags create separate bookmarks or filter existing ones
|
||||
|
||||
**Test Command**:
|
||||
```javascript
|
||||
// Test 6: Bundle tag isolation
|
||||
async function testBundleIsolation(serverUrl, workKey, personalKey) {
|
||||
try {
|
||||
// Create bundle tag bookmark
|
||||
const bookmark = await createBookmark(serverUrl, workKey, 'https://bundle-test.com', {
|
||||
title: 'Bundle Test',
|
||||
notes: JSON.stringify({
|
||||
path: 'Test/Path',
|
||||
userNotes: 'Initial',
|
||||
bundleTag: 'work-bundle',
|
||||
autoTags: [{name: 'work-bundle'}]
|
||||
})
|
||||
});
|
||||
console.log('Bookmark created:', bookmark.id);
|
||||
|
||||
// Query by bundle tag
|
||||
const workBundleResponse = await fetch(`${serverUrl}api/bookmarks/?all=work-bundle`, {
|
||||
headers: {
|
||||
'Authorization': `Token ${workKey}`,
|
||||
}
|
||||
});
|
||||
|
||||
const personalBundleResponse = await fetch(`${serverUrl}api/bookmarks/?all=personal-bundle`, {
|
||||
headers: {
|
||||
'Authorization': `Token ${workKey}`,
|
||||
}
|
||||
});
|
||||
|
||||
const workCount = await workBundleResponse.json();
|
||||
const personalCount = await personalBundleResponse.json();
|
||||
|
||||
console.log(`Work bundle has ${workCount.count || workCount.results?.length || 0} bookmarks`);
|
||||
console.log(`Personal bundle has ${personalCount.count || personalCount.results?.length || 0} bookmarks`);
|
||||
|
||||
if (workCount.count === 1 && personalCount.count === 0) {
|
||||
console.log('RESULT: Bundle tags provide proper isolation');
|
||||
} else {
|
||||
console.log('RESULT: Bundle tags may not provide isolation');
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error:', error.message);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Session Management for Automated Tests
|
||||
|
||||
### Challenge: Authentication Context Switching
|
||||
|
||||
When running automated tests, we need to manage switching between:
|
||||
- Different API keys
|
||||
- Different users (if applicable)
|
||||
- Different bundle contexts
|
||||
|
||||
### Solution: Context Switching Function
|
||||
|
||||
```javascript
|
||||
// Session management for tests
|
||||
const SessionManager = {
|
||||
// Store active context
|
||||
context: {
|
||||
serverUrl: '',
|
||||
currentApiKey: '',
|
||||
currentUserId: null,
|
||||
currentBundle: null
|
||||
},
|
||||
|
||||
// Set new context
|
||||
setContext(serverUrl, apiKey, userId = null, bundle = null) {
|
||||
console.log(`Switching context: ${apiKey.substring(0, 8)}...`);
|
||||
this.context.serverUrl = serverUrl;
|
||||
this.context.currentApiKey = apiKey;
|
||||
this.context.currentUserId = userId;
|
||||
this.context.currentBundle = bundle;
|
||||
|
||||
// Return auth headers for this context
|
||||
return {
|
||||
headers: {
|
||||
'Authorization': `Token ${apiKey}`
|
||||
}
|
||||
};
|
||||
},
|
||||
|
||||
// Make API call with current context
|
||||
async call(endpoint, method = 'GET', body = null) {
|
||||
const headers = {
|
||||
...this.getHeaders(),
|
||||
'Content-Type': 'application/json'
|
||||
};
|
||||
|
||||
if (body) headers['Content-Type'] = 'application/json';
|
||||
|
||||
const response = await fetch(`${this.context.serverUrl}${endpoint}`, {
|
||||
method,
|
||||
headers,
|
||||
body: body ? JSON.stringify(body) : null
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`${response.status}: ${response.statusText}`);
|
||||
}
|
||||
|
||||
return await response.json();
|
||||
},
|
||||
|
||||
// Get headers for current context
|
||||
getHeaders() {
|
||||
return {
|
||||
'Authorization': `Token ${this.context.currentApiKey}`
|
||||
};
|
||||
},
|
||||
|
||||
// Reset to default context
|
||||
reset() {
|
||||
this.context = {
|
||||
serverUrl: '',
|
||||
currentApiKey: '',
|
||||
currentUserId: null,
|
||||
currentBundle: null
|
||||
};
|
||||
console.log('Session reset');
|
||||
}
|
||||
};
|
||||
|
||||
// Usage example:
|
||||
// SessionManager.setContext('https://links.blabber1565.com', 'work-api-key-123');
|
||||
// const bookmark = await SessionManager.call('/api/bookmarks/', 'POST', {url: '...'});
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Test Execution Script
|
||||
|
||||
Create a file: `test-runner.js` in the LinkdingSync directory:
|
||||
|
||||
```javascript
|
||||
/*
|
||||
* Test Runner for Linkding Server Behavior Validation
|
||||
*
|
||||
* Instructions:
|
||||
* 1. Load this file into Firefox DevTools console
|
||||
* 2. Enter your test configuration
|
||||
* 3. Run test suite
|
||||
*/
|
||||
|
||||
// Configuration
|
||||
const CONFIG = {
|
||||
serverUrl: 'https://links.blabber1565.com',
|
||||
workApiKey: '', // Fill in your work API key
|
||||
personalApiKey: '', // Fill in your personal API key
|
||||
workBundle: 'work-bundle',
|
||||
personalBundle: 'personal-bundle'
|
||||
};
|
||||
|
||||
// Import SessionManager
|
||||
// (paste SessionManager code from above)
|
||||
|
||||
// Test Suite
|
||||
async function runTestSuite() {
|
||||
console.log('=== Linkding Server Behavior Test Suite ===\n');
|
||||
|
||||
try {
|
||||
// Setup context
|
||||
SessionManager.setContext(CONFIG.serverUrl, CONFIG.workApiKey, 'user_work', CONFIG.workBundle);
|
||||
|
||||
// Run tests
|
||||
await testSameUserDifferentKeys(CONFIG.serverUrl, CONFIG.workApiKey, CONFIG.personalApiKey);
|
||||
console.log('\n--- Test 1 Complete ---\n');
|
||||
|
||||
await testDifferentUsersNoSharing(CONFIG.serverUrl, CONFIG.workApiKey, CONFIG.personalApiKey);
|
||||
console.log('\n--- Test 2 Complete ---\n');
|
||||
|
||||
await testConflictResolution(CONFIG.serverUrl, CONFIG.workApiKey, CONFIG.personalApiKey);
|
||||
console.log('\n--- Test 3 Complete ---\n');
|
||||
|
||||
await testTitleConflict(CONFIG.serverUrl, CONFIG.workApiKey, CONFIG.personalApiKey);
|
||||
console.log('\n--- Test 4 Complete ---\n');
|
||||
|
||||
await testDeletePropagation(CONFIG.serverUrl, CONFIG.workApiKey, CONFIG.personalApiKey);
|
||||
console.log('\n--- Test 5 Complete ---\n');
|
||||
|
||||
await testBundleIsolation(CONFIG.serverUrl, CONFIG.workApiKey, CONFIG.personalApiKey);
|
||||
console.log('\n--- Test 6 Complete ---\n');
|
||||
|
||||
console.log('=== All Tests Complete ===');
|
||||
|
||||
} catch (error) {
|
||||
console.error('Test suite failed:', error.message);
|
||||
}
|
||||
}
|
||||
|
||||
// Initialize
|
||||
console.log('Type: runTestSuite() to execute all tests');
|
||||
console.log('Type: runTestSuite("scenario") to run specific scenario');
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Expected Outcomes & Decision Points
|
||||
|
||||
After running Phase 0 tests, we'll make decisions based on:
|
||||
|
||||
### Decision Point 1: API Key Isolation
|
||||
- **If same user**: API keys don't provide isolation → Use separate users or different accounts
|
||||
- **If different users**: Confirm sharing settings needed
|
||||
|
||||
### Decision Point 2: Conflict Resolution Strategy
|
||||
- **If last-write-wins**: May cause toggle issues between browsers
|
||||
- **If merge**: Determine merge strategy (path, title, etc.)
|
||||
- **If duplicates**: Accept separate bookmarks per context
|
||||
|
||||
### Decision Point 3: Bundle Isolation
|
||||
- **If tags isolate**: Use bundle tags for isolation
|
||||
- **If not**: Need user accounts for isolation
|
||||
|
||||
---
|
||||
|
||||
## Next Steps
|
||||
|
||||
1. **Create Test Accounts**: You create the 2 user accounts with API keys
|
||||
2. **Configure Test Harness**: Fill in CONFIG with API keys
|
||||
3. **Run Tests**: Execute `runTestSuite()` in Firefox console
|
||||
4. **Analyze Results**: Review test outputs
|
||||
5. **Finalize Design**: Update architecture based on findings
|
||||
|
||||
---
|
||||
|
||||
## Notes
|
||||
|
||||
- Tests are idempotent - they clean up test bookmarks after execution
|
||||
- All test bookmarks are tagged with "test-" prefix for easy identification
|
||||
- Test results will inform the final redesign of LinkdingSync extension
|
||||
Reference in New Issue
Block a user