1
0
mirror of https://github.com/laurent22/joplin.git synced 2025-08-24 20:19:10 +02:00

Chore: Sync fuzzer: Support testing Joplin Cloud readonly shares (#13003)

This commit is contained in:
Henry Heino
2025-08-22 01:33:54 -07:00
committed by GitHub
parent ae170e0aa0
commit 3aac6043da
9 changed files with 352 additions and 96 deletions

View File

@@ -1774,6 +1774,7 @@ packages/tools/fuzzer/Client.js
packages/tools/fuzzer/ClientPool.js
packages/tools/fuzzer/Server.js
packages/tools/fuzzer/constants.js
packages/tools/fuzzer/model/FolderRecord.js
packages/tools/fuzzer/sync-fuzzer.js
packages/tools/fuzzer/types.js
packages/tools/fuzzer/utils/SeededRandom.js

1
.gitignore vendored
View File

@@ -1747,6 +1747,7 @@ packages/tools/fuzzer/Client.js
packages/tools/fuzzer/ClientPool.js
packages/tools/fuzzer/Server.js
packages/tools/fuzzer/constants.js
packages/tools/fuzzer/model/FolderRecord.js
packages/tools/fuzzer/sync-fuzzer.js
packages/tools/fuzzer/types.js
packages/tools/fuzzer/utils/SeededRandom.js

View File

@@ -149,6 +149,7 @@ class Command extends BaseCommand {
waiting: invitation.status === ShareUserStatus.Waiting,
rejected: invitation.status === ShareUserStatus.Rejected,
folderId: invitation.share.folder_id,
canWrite: !!invitation.can_write,
fromUser: {
email: invitation.share.user?.email,
},

View File

@@ -1,6 +1,7 @@
import { strict as assert } from 'assert';
import { ActionableClient, FolderData, FolderMetadata, FuzzContext, ItemId, NoteData, TreeItem, assertIsFolder, isFolder } from './types';
import { ActionableClient, FolderData, FuzzContext, ItemId, NoteData, ShareOptions, TreeItem, assertIsFolder, isFolder } from './types';
import type Client from './Client';
import FolderRecord from './model/FolderRecord';
interface ClientData {
childIds: ItemId[];
@@ -11,6 +12,26 @@ class ActionTracker {
private tree_: Map<string, ClientData> = new Map();
public constructor(private readonly context_: FuzzContext) {}
private getToplevelParent_(item: ItemId|TreeItem) {
let itemId = typeof item === 'string' ? item : item.id;
const originalItemId = itemId;
const seenIds = new Set<ItemId>();
while (this.idToItem_.get(itemId)?.parentId) {
seenIds.add(itemId);
itemId = this.idToItem_.get(itemId).parentId;
if (seenIds.has(itemId)) {
throw new Error('Assertion failure: Item hierarchy is not a tree.');
}
}
const toplevelItem = this.idToItem_.get(itemId);
assert.ok(toplevelItem, `Parent not found for item, top:${itemId} (started at ${originalItemId})`);
assert.equal(toplevelItem.parentId, '', 'Should be a toplevel item');
return toplevelItem;
}
private checkRep_() {
const checkItem = (itemId: ItemId) => {
assert.match(itemId, /^[a-zA-Z0-9]{32}$/, 'item IDs should be 32 character alphanumeric strings');
@@ -33,13 +54,16 @@ class ActionTracker {
// Shared folders
assert.ok(item.ownedByEmail, 'all folders should have a "shareOwner" property (even if not shared)');
assert.ok(!item.sharedWith.includes(item.ownedByEmail), 'the share owner should not be in an item\'s sharedWith list');
if (item.sharedWith.length > 0) {
if (item.isRootSharedItem) {
assert.equal(item.parentId, '', 'only toplevel folders should be shared');
}
for (const sharedWith of item.sharedWith) {
for (const sharedWith of item.shareRecipients) {
assert.ok(this.tree_.has(sharedWith), 'all sharee users should exist');
}
// isSharedWith is only valid for toplevel folders
if (item.parentId === '') {
assert.ok(!item.isSharedWith(item.ownedByEmail), 'the share owner should not be in an item\'s sharedWith list');
}
// Uniqueness
assert.equal(
@@ -82,10 +106,7 @@ class ActionTracker {
if (!parent) throw new Error(`Parent with ID ${parentId} not found.`);
if (!isFolder(parent)) throw new Error(`Item ${parentId} is not a folder`);
this.idToItem_.set(parentId, {
...parent,
childIds: updateFn(parent.childIds),
});
this.idToItem_.set(parentId, parent.withChildren(updateFn(parent.childIds)));
};
const addRootItem = (itemId: ItemId) => {
const clientData = this.tree_.get(clientId);
@@ -220,14 +241,40 @@ class ActionTracker {
return result;
};
const listFoldersDetailed = () => {
return mapItems((item): FolderData => {
const getAllFolders = () => {
return mapItems((item): FolderRecord => {
return isFolder(item) ? item : null;
}).filter(item => !!item);
};
const isReadOnly = (item: ItemId|TreeItem) => {
if (item === '') return false;
const toplevelItem = this.getToplevelParent_(item);
assertIsFolder(toplevelItem);
return toplevelItem.isReadOnlySharedWith(clientId);
};
const isShared = (item: TreeItem) => {
const toplevelItem = this.getToplevelParent_(item);
assertIsFolder(toplevelItem);
return toplevelItem.isRootSharedItem;
};
const assertWriteable = (item: ItemId|TreeItem) => {
if (typeof item !== 'string') {
item = item.id;
}
if (isReadOnly(item)) {
throw new Error(`Item is read-only: ${item}`);
}
};
const tracker: ActionableClient = {
createNote: (data: NoteData) => {
assertWriteable(data.parentId);
assert.ok(!!data.parentId, `note ${data.id} should have a parentId`);
assert.ok(!this.idToItem_.has(data.id), `note ${data.id} should not yet exist`);
this.idToItem_.set(data.id, {
@@ -239,6 +286,8 @@ class ActionTracker {
return Promise.resolve();
},
updateNote: (data: NoteData) => {
assertWriteable(data.parentId);
const oldItem = this.idToItem_.get(data.id);
assert.ok(oldItem, `note ${data.id} should exist`);
assert.ok(!!data.parentId, `note ${data.id} should have a parentId`);
@@ -252,14 +301,17 @@ class ActionTracker {
this.checkRep_();
return Promise.resolve();
},
createFolder: (data: FolderMetadata) => {
this.idToItem_.set(data.id, {
createFolder: (data: FolderData) => {
const parentId = data.parentId ?? '';
assertWriteable(parentId);
this.idToItem_.set(data.id, new FolderRecord({
...data,
parentId: data.parentId ?? '',
parentId: parentId ?? '',
childIds: getChildIds(data.id),
sharedWith: [],
ownedByEmail: clientId,
});
}));
addChild(data.parentId, data.id);
this.checkRep_();
@@ -270,18 +322,19 @@ class ActionTracker {
const item = this.idToItem_.get(id);
if (!item) throw new Error(`Not found ${id}`);
if (!isFolder(item)) throw new Error(`Not a folder ${id}`);
assertIsFolder(item);
assertWriteable(item);
removeItemRecursive(id);
this.checkRep_();
return Promise.resolve();
},
shareFolder: (id: ItemId, shareWith: Client) => {
shareFolder: (id: ItemId, shareWith: Client, options: ShareOptions) => {
const itemToShare = this.idToItem_.get(id);
assertIsFolder(itemToShare);
const alreadyShared = itemToShare.sharedWith.includes(shareWith.email);
const alreadyShared = itemToShare.isSharedWith(shareWith.email);
assert.ok(!alreadyShared, `Folder ${id} should not yet be shared with ${shareWith.email}`);
const shareWithChildIds = this.tree_.get(shareWith.email).childIds;
@@ -294,10 +347,9 @@ class ActionTracker {
childIds: [...shareWithChildIds, id],
});
this.idToItem_.set(id, {
...itemToShare,
sharedWith: [...itemToShare.sharedWith, shareWith.email],
});
this.idToItem_.set(
id, itemToShare.withShared(shareWith.email, options.readOnly),
);
this.checkRep_();
return Promise.resolve();
@@ -306,7 +358,7 @@ class ActionTracker {
const targetItem = this.idToItem_.get(id);
assertIsFolder(targetItem);
assert.ok(targetItem.sharedWith.includes(shareWith.email), `Folder ${id} should be shared with ${shareWith.label}`);
assert.ok(targetItem.isSharedWith(shareWith.email), `Folder ${id} should be shared with ${shareWith.label}`);
const otherSubTree = this.tree_.get(shareWith.email);
this.tree_.set(shareWith.email, {
@@ -314,35 +366,39 @@ class ActionTracker {
childIds: otherSubTree.childIds.filter(childId => childId !== id),
});
this.idToItem_.set(id, {
...targetItem,
sharedWith: targetItem.sharedWith.filter(shareeEmail => shareeEmail !== shareWith.email),
});
this.idToItem_.set(id, targetItem.withUnshared(shareWith.email));
this.checkRep_();
return Promise.resolve();
},
moveItem: (itemId, newParentId) => {
const item = this.idToItem_.get(itemId);
assert.ok(item, `item with ${itemId} should exist`);
if (newParentId) {
const parent = this.idToItem_.get(newParentId);
assert.ok(parent, `parent with ID ${newParentId} should exist`);
} else {
assert.equal(newParentId, '', 'parentId should be empty if a toplevel folder');
}
const validateParameters = () => {
assert.ok(item, `item with ${itemId} should exist`);
if (isFolder(item)) {
assert.deepEqual(item.sharedWith, [], 'cannot move toplevel shared folders without first unsharing');
}
if (newParentId) {
const parent = this.idToItem_.get(newParentId);
assert.ok(parent, `parent with ID ${newParentId} should exist`);
} else {
assert.equal(newParentId, '', 'parentId should be empty if a toplevel folder');
}
if (isFolder(item)) {
assert.equal(item.isRootSharedItem, false, 'cannot move toplevel shared folders without first unsharing');
}
assertWriteable(itemId);
assertWriteable(newParentId);
};
validateParameters();
removeChild(item.parentId, itemId);
addChild(newParentId, itemId);
this.idToItem_.set(itemId, {
...item,
parentId: newParentId,
});
this.idToItem_.set(
itemId,
isFolder(item) ? item.withParent(newParentId) : { ...item, parentId: newParentId },
);
this.checkRep_();
return Promise.resolve();
@@ -351,17 +407,21 @@ class ActionTracker {
listNotes: () => {
const notes = mapItems(item => {
return isFolder(item) ? null : item;
}).filter(item => !!item);
}).filter(item => !!item).map(item => ({
...item,
isShared: isShared(item),
}));
this.checkRep_();
return Promise.resolve(notes);
},
listFolders: () => {
this.checkRep_();
const folderData = listFoldersDetailed().map(item => ({
const folderData = getAllFolders().map(item => ({
id: item.id,
title: item.title,
parentId: item.parentId,
isShared: isShared(item),
}));
return Promise.resolve(folderData);
@@ -390,10 +450,13 @@ class ActionTracker {
return Promise.resolve(descendants);
},
randomFolder: async (options) => {
let folders = listFoldersDetailed();
let folders = getAllFolders();
if (options.filter) {
folders = folders.filter(options.filter);
}
if (!options.includeReadOnly) {
folders = folders.filter(folder => !isReadOnly(folder.id));
}
const folderIndex = this.context_.randInt(0, folders.length);
return folders.length ? folders[folderIndex] : null;

View File

@@ -1,5 +1,5 @@
import uuid, { createSecureRandom } from '@joplin/lib/uuid';
import { ActionableClient, FolderMetadata, FuzzContext, HttpMethod, ItemId, Json, NoteData, RandomFolderOptions } from './types';
import { ActionableClient, FolderData, FuzzContext, HttpMethod, ItemId, Json, NoteData, RandomFolderOptions, RandomNoteOptions, ShareOptions } from './types';
import { join } from 'path';
import { mkdir, remove } from 'fs-extra';
import getStringProperty from './utils/getStringProperty';
@@ -128,10 +128,11 @@ class Client implements ActionableClient {
account.onClientConnected();
// Joplin Server sync
await client.execCliCommand_('config', 'sync.target', '9');
await client.execCliCommand_('config', 'sync.9.path', context.serverUrl);
await client.execCliCommand_('config', 'sync.9.username', account.email);
await client.execCliCommand_('config', 'sync.9.password', account.password);
const targetId = context.isJoplinCloud ? '10' : '9';
await client.execCliCommand_('config', 'sync.target', targetId);
await client.execCliCommand_('config', `sync.${targetId}.path`, context.serverUrl);
await client.execCliCommand_('config', `sync.${targetId}.username`, account.email);
await client.execCliCommand_('config', `sync.${targetId}.password`, account.password);
await client.execCliCommand_('config', 'api.token', apiData.token);
await client.execCliCommand_('config', 'api.port', String(apiData.port));
@@ -448,7 +449,7 @@ class Client implements ActionableClient {
});
}
public async createFolder(folder: FolderMetadata) {
public async createFolder(folder: FolderData) {
logger.info('Create folder', folder.id, 'in', `${folder.parentId ?? 'root'}/${this.label}`);
await this.tracker_.createFolder(folder);
@@ -510,8 +511,8 @@ class Client implements ActionableClient {
await this.execCliCommand_('rmbook', '--permanent', '--force', id);
}
public async shareFolder(id: string, shareWith: Client) {
await this.tracker_.shareFolder(id, shareWith);
public async shareFolder(id: string, shareWith: Client, options: ShareOptions) {
await this.tracker_.shareFolder(id, shareWith, options);
const getPendingInvitations = async (target: Client) => {
const shareWithIncoming = JSON.parse((await target.execCliCommand_('share', 'list', '--json')).stdout);
@@ -524,8 +525,11 @@ class Client implements ActionableClient {
};
await retryWithCount(async () => {
logger.info('Share', id, 'with', shareWith.label);
await this.execCliCommand_('share', 'add', id, shareWith.email);
logger.info('Share', id, 'with', shareWith.label, options.readOnly ? '(read-only)' : '');
const readOnlyArgs = options.readOnly ? ['--read-only'] : [];
await this.execCliCommand_(
'share', 'add', ...readOnlyArgs, id, shareWith.email,
);
await this.sync();
await shareWith.sync();
@@ -536,6 +540,7 @@ class Client implements ActionableClient {
accepted: false,
waiting: true,
rejected: false,
canWrite: !options.readOnly,
folderId: id,
fromUser: {
email: this.email,
@@ -571,7 +576,7 @@ class Client implements ActionableClient {
public async listNotes() {
const params = {
fields: 'id,parent_id,body,title,is_conflict,conflict_original_id',
fields: 'id,parent_id,body,title,is_conflict,conflict_original_id,share_id',
include_deleted: '1',
include_conflicts: '1',
};
@@ -586,13 +591,14 @@ class Client implements ActionableClient {
) : getStringProperty(item, 'parent_id'),
title: getStringProperty(item, 'title'),
body: getStringProperty(item, 'body'),
isShared: getStringProperty(item, 'share_id') !== '',
}),
);
}
public async listFolders() {
const params = {
fields: 'id,parent_id,title',
fields: 'id,parent_id,title,share_id',
include_deleted: '1',
};
return await this.execPagedApiCommand_(
@@ -603,6 +609,7 @@ class Client implements ActionableClient {
id: getStringProperty(item, 'id'),
parentId: getStringProperty(item, 'parent_id'),
title: getStringProperty(item, 'title'),
isShared: getStringProperty(item, 'share_id') !== '',
}),
);
}
@@ -615,8 +622,8 @@ class Client implements ActionableClient {
return this.tracker_.allFolderDescendants(parentId);
}
public async randomNote() {
return this.tracker_.randomNote();
public async randomNote(options: RandomNoteOptions) {
return this.tracker_.randomNote(options);
}
public async checkState() {

View File

@@ -1,6 +1,5 @@
import { join } from 'path';
import { join, resolve } from 'path';
import { HttpMethod, Json, UserData } from './types';
import { packagesDir } from './constants';
import JoplinServerApi from '@joplin/lib/JoplinServerApi';
import { Env } from '@joplin/lib/models/Setting';
import execa = require('execa');
@@ -27,16 +26,17 @@ export default class Server {
private server_: execa.ExecaChildProcess<string>;
public constructor(
serverBaseDirectory: string,
private readonly serverUrl_: string,
private readonly adminAuth_: UserData,
) {
const serverDir = join(packagesDir, 'server');
const serverDir = resolve(serverBaseDirectory);
const mainEntrypoint = join(serverDir, 'dist', 'app.js');
this.server_ = execa.node(mainEntrypoint, [
'--env', 'dev',
], {
env: { JOPLIN_IS_TESTING: '1' },
cwd: join(packagesDir, 'server'),
cwd: serverDir,
stdin: 'ignore', // No stdin
// For debugging:
// stderr: process.stderr,

View File

@@ -0,0 +1,144 @@
import { strict as assert } from 'node:assert';
import type { FolderData, ItemId } from '../types';
export type ShareRecord = {
email: string;
readOnly: boolean;
};
interface InitializationOptions extends FolderData {
childIds: ItemId[];
sharedWith: ShareRecord[];
// Email of the Joplin Server account that controls the item
ownedByEmail: string;
}
const validateId = (id: string) => {
return !!id.match(/^[a-zA-Z0-9]{32}$/);
};
export default class FolderRecord implements FolderData {
public readonly parentId: string;
public readonly id: string;
public readonly title: string;
public readonly ownedByEmail: string;
public readonly childIds: ItemId[];
private readonly sharedWith_: ShareRecord[];
public constructor(options: InitializationOptions) {
this.parentId = options.parentId;
this.id = options.id;
this.title = options.title;
this.childIds = options.childIds;
this.ownedByEmail = options.ownedByEmail;
this.sharedWith_ = options.sharedWith;
if (this.parentId !== '' && !validateId(this.parentId)) {
throw new Error(`Invalid parent ID: ${this.parentId}`);
}
if (!validateId(this.id)) {
throw new Error(`Invalid ID: ${this.id}`);
}
}
public get shareRecipients() {
return this.sharedWith_.map(sharee => sharee.email);
}
private get metadata_(): InitializationOptions {
return {
parentId: this.parentId,
id: this.id,
title: this.title,
ownedByEmail: this.ownedByEmail,
childIds: [...this.childIds],
sharedWith: [...this.sharedWith_],
};
}
public get isRootSharedItem() {
return this.sharedWith_.length > 0;
}
public isSharedWith(email: string) {
assert.equal(this.parentId, '', 'only supported for toplevel folders');
return this.sharedWith_.some(record => record.email === email);
}
public isReadOnlySharedWith(email: string) {
assert.equal(this.parentId, '', 'only supported for toplevel folders');
return this.sharedWith_.some(record => record.email === email && record.readOnly);
}
public withTitle(title: string) {
return new FolderRecord({
...this.metadata_,
title,
});
}
public withParent(parentId: ItemId) {
return new FolderRecord({
...this.metadata_,
parentId,
});
}
public withId(id: ItemId) {
return new FolderRecord({
...this.metadata_,
id,
});
}
public withChildren(childIds: ItemId[]) {
return new FolderRecord({
...this.metadata_,
childIds: [...childIds],
});
}
public withChildAdded(childId: ItemId) {
if (this.childIds.includes(childId)) {
return this;
}
return this.withChildren([...this.childIds, childId]);
}
public withChildRemoved(childId: ItemId) {
return this.withChildren(
this.childIds.filter(id => id !== childId),
);
}
public withShared(recipientEmail: string, readOnly: boolean) {
if (this.isSharedWith(recipientEmail) && this.isReadOnlySharedWith(recipientEmail) === readOnly) {
return this;
}
if (this.parentId !== '') {
throw new Error('Cannot share non-top-level folder');
}
return new FolderRecord({
...this.metadata_,
sharedWith: [
...this.sharedWith_.filter(record => record.email !== recipientEmail),
{ email: recipientEmail, readOnly },
],
});
}
public withUnshared(recipientEmail: string) {
if (!this.isSharedWith(recipientEmail)) {
return this;
}
return new FolderRecord({
...this.metadata_,
sharedWith: this.sharedWith_.filter(record => record.email !== recipientEmail),
});
}
}

View File

@@ -14,6 +14,7 @@ import yargs = require('yargs');
import { strict as assert } from 'assert';
import openDebugSession from './utils/openDebugSession';
import { Second } from '@joplin/utils/time';
import { packagesDir } from './constants';
const { shimInit } = require('@joplin/lib/shim-init-node');
const globalLogger = new Logger();
@@ -42,7 +43,7 @@ const createProfilesDirectory = async () => {
const doRandomAction = async (context: FuzzContext, client: Client, clientPool: ClientPool) => {
const selectOrCreateParentFolder = async () => {
let parentId = (await client.randomFolder({}))?.id;
let parentId = (await client.randomFolder({ includeReadOnly: false }))?.id;
// Create a toplevel folder to serve as this
// folder's parent if none exist yet
@@ -58,8 +59,9 @@ const doRandomAction = async (context: FuzzContext, client: Client, clientPool:
return parentId;
};
const selectOrCreateNote = async () => {
let note = await client.randomNote();
const selectOrCreateWriteableNote = async () => {
const options = { includeReadOnly: false };
let note = await client.randomNote(options);
if (!note) {
await client.createNote({
@@ -69,7 +71,7 @@ const doRandomAction = async (context: FuzzContext, client: Client, clientPool:
body: 'Body',
});
note = await client.randomNote();
note = await client.randomNote(options);
assert.ok(note, 'should have selected a random note');
}
@@ -111,7 +113,7 @@ const doRandomAction = async (context: FuzzContext, client: Client, clientPool:
return true;
},
renameNote: async () => {
const note = await selectOrCreateNote();
const note = await selectOrCreateWriteableNote();
await client.updateNote({
...note,
@@ -121,7 +123,7 @@ const doRandomAction = async (context: FuzzContext, client: Client, clientPool:
return true;
},
updateNoteBody: async () => {
const note = await selectOrCreateNote();
const note = await selectOrCreateWriteableNote();
await client.updateNote({
...note,
@@ -131,10 +133,10 @@ const doRandomAction = async (context: FuzzContext, client: Client, clientPool:
return true;
},
moveNote: async () => {
const note = await client.randomNote();
if (!note) return false;
const note = await selectOrCreateWriteableNote();
const targetParent = await client.randomFolder({
filter: folder => folder.id !== note.parentId,
includeReadOnly: false,
});
if (!targetParent) return false;
@@ -150,32 +152,35 @@ const doRandomAction = async (context: FuzzContext, client: Client, clientPool:
filter: candidate => {
const isToplevel = !candidate.parentId;
const ownedByCurrent = candidate.ownedByEmail === client.email;
const alreadyShared = candidate.sharedWith.includes(other.email);
const alreadyShared = isToplevel && candidate.isSharedWith(other.email);
return isToplevel && ownedByCurrent && !alreadyShared;
},
includeReadOnly: true,
});
if (!target) return false;
await client.shareFolder(target.id, other);
const readOnly = context.randInt(0, 2) === 1 && context.isJoplinCloud;
await client.shareFolder(target.id, other, { readOnly });
return true;
},
unshareFolder: async () => {
const target = await client.randomFolder({
filter: candidate => {
return candidate.sharedWith.length > 0 && candidate.ownedByEmail === client.email;
return candidate.isRootSharedItem && candidate.ownedByEmail === client.email;
},
includeReadOnly: true,
});
if (!target) return false;
const recipientIndex = context.randInt(0, target.sharedWith.length);
const recipientEmail = target.sharedWith[recipientIndex];
const recipientIndex = context.randInt(0, target.shareRecipients.length);
const recipientEmail = target.shareRecipients[recipientIndex];
const recipient = clientPool.clientsByEmail(recipientEmail)[0];
assert.ok(recipient, `invalid state -- recipient ${recipientEmail} should exist`);
await client.removeFromShare(target.id, recipient);
return true;
},
deleteFolder: async () => {
const target = await client.randomFolder({});
const target = await client.randomFolder({ includeReadOnly: false });
if (!target) return false;
await client.deleteFolder(target.id);
@@ -185,6 +190,7 @@ const doRandomAction = async (context: FuzzContext, client: Client, clientPool:
const target = await client.randomFolder({
// Don't choose items that are already toplevel
filter: item => !!item.parentId,
includeReadOnly: false,
});
if (!target) return false;
@@ -194,7 +200,8 @@ const doRandomAction = async (context: FuzzContext, client: Client, clientPool:
moveFolderTo: async () => {
const target = await client.randomFolder({
// Don't move shared folders (should not be allowed by the GUI in the main apps).
filter: item => item.sharedWith.length === 0,
filter: item => !item.isRootSharedItem,
includeReadOnly: false,
});
if (!target) return false;
@@ -205,6 +212,7 @@ const doRandomAction = async (context: FuzzContext, client: Client, clientPool:
// Avoid making the folder a child of itself
return !targetDescendants.has(item.id);
},
includeReadOnly: false,
});
if (!newParent) return false;
@@ -288,6 +296,9 @@ interface Options {
maximumSteps: number;
maximumStepsBetweenSyncs: number;
clientCount: number;
serverPath: string;
isJoplinCloud: boolean;
}
const main = async (options: Options) => {
@@ -319,7 +330,7 @@ const main = async (options: Options) => {
try {
const joplinServerUrl = 'http://localhost:22300/';
const server = new Server(joplinServerUrl, {
const server = new Server(options.serverPath, joplinServerUrl, {
email: 'admin@localhost',
password: env['FUZZER_SERVER_ADMIN_PASSWORD'] ?? 'admin',
});
@@ -335,8 +346,13 @@ const main = async (options: Options) => {
logger.info('Starting with seed', options.seed);
const random = new SeededRandom(options.seed);
if (options.isJoplinCloud) {
logger.info('Sync target: Joplin Cloud');
}
const fuzzContext: FuzzContext = {
serverUrl: joplinServerUrl,
isJoplinCloud: options.isJoplinCloud,
baseDir: profilesDirectory.path,
execApi: server.execApi.bind(server),
randInt: (a, b) => random.nextInRange(a, b),
@@ -425,13 +441,24 @@ void yargs
default: 3,
defaultDescription: 'Number of client apps to create.',
},
'joplin-cloud': {
type: 'string',
default: '',
defaultDescription: [
'A path: If provided, this should be an absolute path to a Joplin Cloud repository. ',
'This also enables testing for some Joplin Cloud-specific features (e.g. read-only shares).',
].join(''),
},
});
},
async (argv) => {
const serverPath = argv.joplinCloud ? argv.joplinCloud : join(packagesDir, 'server');
await main({
seed: argv.seed,
maximumSteps: argv.steps,
clientCount: argv.clients,
serverPath: serverPath,
isJoplinCloud: !!argv.joplinCloud,
maximumStepsBetweenSyncs: argv['steps-between-syncs'],
});
},

View File

@@ -1,36 +1,38 @@
import type Client from './Client';
import type FolderRecord from './model/FolderRecord';
export type Json = string|number|Json[]|{ [key: string]: Json };
export type HttpMethod = 'GET'|'POST'|'DELETE'|'PUT'|'PATCH';
export type ItemId = string;
export type NoteData = {
export interface NoteData {
parentId: ItemId;
id: ItemId;
title: string;
body: string;
};
export type FolderMetadata = {
}
export interface DetailedNoteData extends NoteData {
isShared: boolean;
}
export interface FolderData {
parentId: ItemId;
id: ItemId;
title: string;
};
export type FolderData = FolderMetadata & {
childIds: ItemId[];
sharedWith: string[];
// Email of the Joplin Server account that controls the item
ownedByEmail: string;
};
export type TreeItem = NoteData|FolderData;
}
export interface DetailedFolderData extends FolderData {
isShared: boolean;
}
export const isFolder = (item: TreeItem): item is FolderData => {
export type TreeItem = NoteData|FolderRecord;
export const isFolder = (item: TreeItem): item is FolderRecord => {
return 'childIds' in item;
};
// Typescript type assertions require type definitions on the left for arrow functions.
// See https://github.com/microsoft/TypeScript/issues/53450.
export const assertIsFolder: (item: TreeItem)=> asserts item is FolderData = item => {
export const assertIsFolder: (item: TreeItem)=> asserts item is FolderRecord = item => {
if (!item) {
throw new Error(`Item ${item} is not a folder`);
}
@@ -42,18 +44,28 @@ export const assertIsFolder: (item: TreeItem)=> asserts item is FolderData = ite
export interface FuzzContext {
serverUrl: string;
isJoplinCloud: boolean;
baseDir: string;
execApi: (method: HttpMethod, route: string, debugAction: Json)=> Promise<Json>;
randInt: (low: number, high: number)=> number;
}
export interface RandomFolderOptions {
filter?: (folder: FolderData)=> boolean;
includeReadOnly: boolean;
filter?: (folder: FolderRecord)=> boolean;
}
export interface RandomNoteOptions {
includeReadOnly: boolean;
}
export interface ShareOptions {
readOnly: boolean;
}
export interface ActionableClient {
createFolder(data: FolderMetadata): Promise<void>;
shareFolder(id: ItemId, shareWith: Client): Promise<void>;
createFolder(data: FolderData): Promise<void>;
shareFolder(id: ItemId, shareWith: Client, options: ShareOptions): Promise<void>;
removeFromShare(id: string, shareWith: Client): Promise<void>;
deleteFolder(id: ItemId): Promise<void>;
createNote(data: NoteData): Promise<void>;
@@ -62,10 +74,10 @@ export interface ActionableClient {
sync(): Promise<void>;
listNotes(): Promise<NoteData[]>;
listFolders(): Promise<FolderMetadata[]>;
listFolders(): Promise<DetailedFolderData[]>;
allFolderDescendants(parentId: ItemId): Promise<ItemId[]>;
randomFolder(options: RandomFolderOptions): Promise<FolderData>;
randomNote(): Promise<NoteData>;
randomFolder(options: RandomFolderOptions): Promise<FolderRecord>;
randomNote(options: RandomNoteOptions): Promise<NoteData>;
}
export interface UserData {