一些检查失败了
automerge / automerge (push) Has been cancelled
react-native-android-build-apk / AssembleRelease (push) Has been cancelled
Build macOS M1 / Main (push) Has been cancelled
Joplin Continuous Integration / Main (macos-15-intel) (push) Has been cancelled
Joplin Continuous Integration / Main (ubuntu-22.04) (push) Has been cancelled
Joplin Continuous Integration / Main (ubuntu-22.04-arm) (push) Has been cancelled
Joplin Continuous Integration / Main (windows-11-vs2026-arm) (push) Has been cancelled
Joplin Continuous Integration / Main (windows-2025) (push) Has been cancelled
Joplin Continuous Integration / ServerDockerImage (ubuntu-22.04) (push) Has been cancelled
Joplin Continuous Integration / ServerDockerImage (ubuntu-22.04-arm) (push) Has been cancelled
Joplin UI tests / Main (ubuntu-22.04) (push) Has been cancelled
Joplin UI tests / Main (windows-2025) (push) Has been cancelled
Joplin UI tests / Mobile (push) Has been cancelled
Close stale issues / ProcessStaleIssues (push) Has been cancelled
542 行
15 KiB
TypeScript
542 行
15 KiB
TypeScript
import Client from './ipc/Client';
|
|
import ClientPool from './ipc/ClientPool';
|
|
import { FuzzContext, RandomFolderOptions } from './types';
|
|
import { assertIsFolder, assertIsNote, ItemId, ResourceData } from './model/types';
|
|
import { strict as assert } from 'assert';
|
|
import Logger from '@joplin/utils/Logger';
|
|
import retryWithCount from './utils/retryWithCount';
|
|
import { Second } from '@joplin/utils/time';
|
|
|
|
const logger = Logger.create('ActionRunner');
|
|
|
|
export interface ActionSpec {
|
|
key: string;
|
|
options: Record<string, string|number>;
|
|
}
|
|
|
|
export default class ActionRunner {
|
|
public constructor(private context_: FuzzContext, private clientPool_: ClientPool, private activeClient_: Client) {}
|
|
|
|
public switchClient(client: Client) {
|
|
this.activeClient_ = client;
|
|
}
|
|
|
|
public async syncAndCheckState() {
|
|
await this.activeClient_.sync();
|
|
|
|
// .checkState can fail occasionally due to incomplete
|
|
// syncs (perhaps because the server is still processing
|
|
// share-related changes?). Allow this to be retried:
|
|
await retryWithCount(async () => {
|
|
await this.clientPool_.checkState();
|
|
}, {
|
|
count: 4,
|
|
delayOnFailure: count => count * Second * 3,
|
|
onFail: async ({ willRetry }) => {
|
|
if (willRetry) {
|
|
logger.info('.checkState failed. Syncing all clients...');
|
|
await this.clientPool_.syncAll();
|
|
}
|
|
},
|
|
});
|
|
}
|
|
|
|
private buildActions_() {
|
|
const { actions, schema, addAction } = getActions(this.context_, this.clientPool_, this.activeClient_);
|
|
|
|
addAction('switchClient', async ({ id }) => {
|
|
if (typeof id !== 'number') throw new Error(`clientId must be a number. Was ${id}`);
|
|
this.switchClient(this.clientPool_.clientById(id));
|
|
return true;
|
|
}, { id: () => this.clientPool_.getClientId(this.clientPool_.randomClient()) });
|
|
|
|
addAction('syncAndCheckState', async () => {
|
|
await this.syncAndCheckState();
|
|
return true;
|
|
}, {});
|
|
|
|
return { actions, schema };
|
|
}
|
|
|
|
private validateActions_(specs: ActionSpec[]) {
|
|
const { schema } = this.buildActions_();
|
|
const errors = [];
|
|
for (const spec of specs) {
|
|
const currentActionLabel = JSON.stringify([spec.key, spec.options]);
|
|
|
|
const supportedOptions = schema.get(spec.key);
|
|
if (!supportedOptions) {
|
|
errors.push(
|
|
`In ${currentActionLabel}: Unknown action: ${spec.key}. Available action keys: ${JSON.stringify([...schema.keys()])}`,
|
|
);
|
|
continue;
|
|
}
|
|
|
|
const providedOptions = Object.keys(spec.options);
|
|
for (const option of providedOptions) {
|
|
if (!supportedOptions.includes(option)) {
|
|
errors.push(`In ${currentActionLabel}: Unknown option: ${option}. Supported options: ${JSON.stringify(supportedOptions)}`);
|
|
}
|
|
}
|
|
}
|
|
|
|
if (errors.length) {
|
|
throw new Error(`Validation failed:\n- ${errors.join('\n- ')}`);
|
|
}
|
|
}
|
|
|
|
public async doActions(specs: ActionSpec[]) {
|
|
this.validateActions_(specs);
|
|
|
|
for (const spec of specs) {
|
|
const { actions } = this.buildActions_();
|
|
|
|
const action = actions.get(spec.key);
|
|
if (!action) throw new Error(`Not found: ${spec.key}`);
|
|
|
|
await action(spec.options);
|
|
}
|
|
}
|
|
|
|
public async doRandomAction() {
|
|
// Avoid running special actions (e.g. "comment")
|
|
const { actions } = this.buildActions_();
|
|
|
|
const actionKeys = [...actions.keys()].filter(key => {
|
|
// Avoid choosing certain actions:
|
|
return key !== 'syncAndCheckState' && key !== 'switchClient' && key !== 'comment';
|
|
});
|
|
|
|
let result = false;
|
|
while (!result) { // Loop until an action was done
|
|
const randomAction = this.context_.randomFrom(actionKeys);
|
|
logger.info(`Action: ${randomAction} in ${this.activeClient_.email}`);
|
|
result = await actions.get(randomAction)({ });
|
|
if (!result) {
|
|
logger.info(` ${randomAction} was skipped (preconditions not met).`);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
type ActionDefaults = { [key: string]: ()=> unknown|Promise<unknown> };
|
|
type ActionOptions<Defaults extends ActionDefaults> = {
|
|
[t in keyof Defaults]: Awaited<ReturnType<Defaults[t]>>
|
|
};
|
|
type UnknownActionOptions = Record<string, unknown>;
|
|
type ActionFunction<Options extends UnknownActionOptions>
|
|
= (options: Options)=> Promise<boolean>;
|
|
|
|
// Creates an action function, with defaults applied and logging
|
|
const createActionFunction = <Defaults extends ActionDefaults> (
|
|
key: string, action: ActionFunction<ActionOptions<Defaults>>, defaults: Defaults,
|
|
): ActionFunction<Partial<ActionOptions<Defaults>>> => {
|
|
return async (options) => {
|
|
const builtOptions: Record<string, unknown> = {};
|
|
for (const key in defaults) {
|
|
const defaultValue = await defaults[key]();
|
|
builtOptions[key] = options[key] ?? defaultValue;
|
|
}
|
|
|
|
logger.info('Run action:', JSON.stringify([key, builtOptions]));
|
|
return action(builtOptions as ActionOptions<Defaults>);
|
|
};
|
|
};
|
|
|
|
const getActions = (context: FuzzContext, clientPool: ClientPool, client: Client) => {
|
|
const selectOrCreateWriteableFolder = async () => {
|
|
let parentId = (await client.randomFolder({ includeReadOnly: false }))?.id;
|
|
|
|
// Create a toplevel folder to serve as this
|
|
// folder's parent if none exist yet
|
|
if (!parentId) {
|
|
parentId = context.randomId();
|
|
await client.createFolder({
|
|
parentId: '',
|
|
id: parentId,
|
|
title: 'Parent folder',
|
|
});
|
|
}
|
|
|
|
return parentId;
|
|
};
|
|
|
|
const defaultNoteProperties = {
|
|
published: false,
|
|
};
|
|
|
|
const selectOrCreateWriteableNote = async () => {
|
|
const options = { includeReadOnly: false };
|
|
let note = await client.randomNote(options);
|
|
|
|
if (!note) {
|
|
await client.createNote({
|
|
...defaultNoteProperties,
|
|
parentId: await selectOrCreateWriteableFolder(),
|
|
id: context.randomId(),
|
|
title: 'Test note',
|
|
body: 'Body',
|
|
});
|
|
|
|
note = await client.randomNote(options);
|
|
assert.ok(note, 'should have selected a random note');
|
|
}
|
|
|
|
return note.id;
|
|
};
|
|
|
|
const noteById = (id: ItemId) => {
|
|
assert.ok(client.itemExists(id), `Could not find note with ID ${id} in client ${client.email}'s expected state.`);
|
|
|
|
const note = client.itemById(id);
|
|
assertIsNote(note);
|
|
return note;
|
|
};
|
|
|
|
const folderById = (id: ItemId) => {
|
|
assert.ok(client.itemExists(id), `Could not find folder with ID ${id} in client ${client.email}'s expected state.`);
|
|
|
|
const folder = client.itemById(id);
|
|
assertIsFolder(folder);
|
|
return folder;
|
|
};
|
|
|
|
const folderByIdOrRandom = (id: ItemId|undefined, randomOptions: RandomFolderOptions) => {
|
|
if (id !== undefined) {
|
|
return folderById(id);
|
|
} else {
|
|
return client.randomFolder(randomOptions);
|
|
}
|
|
};
|
|
|
|
const schema = new Map<string, string[]>; // Maps from keys to supported options
|
|
const actions = new Map<string, ActionFunction<UnknownActionOptions>>();
|
|
const addAction = <T extends ActionDefaults> (key: string, action: ActionFunction<ActionOptions<T>>, defaults: T) => {
|
|
actions.set(key, createActionFunction(key, action, defaults) as ActionFunction<UnknownActionOptions>);
|
|
schema.set(key, Object.keys(defaults));
|
|
};
|
|
|
|
const undefinedId = (): ItemId|undefined => undefined;
|
|
|
|
addAction('newSubfolder', async ({ parentId, id, title }) => {
|
|
await client.createRandomFolder({ parentId, id, title, quiet: false });
|
|
return true;
|
|
}, {
|
|
parentId: selectOrCreateWriteableFolder,
|
|
id: undefinedId,
|
|
title: () => undefined,
|
|
});
|
|
|
|
addAction('newToplevelFolder', async ({ id, title }) => {
|
|
await client.createRandomFolder({ parentId: '', id, title, quiet: false });
|
|
return true;
|
|
}, {
|
|
id: undefinedId,
|
|
title: () => undefined,
|
|
});
|
|
|
|
addAction('newNote', async ({ parentId, id }) => {
|
|
await client.createRandomNote({ parentId, id });
|
|
return true;
|
|
}, {
|
|
parentId: selectOrCreateWriteableFolder,
|
|
id: undefinedId,
|
|
});
|
|
|
|
addAction('renameNote', async ({ id }) => {
|
|
await client.updateNote({
|
|
...noteById(id),
|
|
title: `Renamed (${context.randInt(0, 1000)})`,
|
|
});
|
|
|
|
return true;
|
|
}, { id: selectOrCreateWriteableNote });
|
|
|
|
addAction('updateNoteBody', async ({ id }) => {
|
|
const note = noteById(id);
|
|
|
|
await client.updateNote({
|
|
...note,
|
|
body: `${note.body}\n\nUpdated!`,
|
|
});
|
|
|
|
return true;
|
|
}, { id: selectOrCreateWriteableNote });
|
|
|
|
addAction('attachResourceTo', async ({ noteId, resourceId }) => {
|
|
const resourceData: ResourceData = {
|
|
id: resourceId,
|
|
mimeType: 'text/plain',
|
|
title: 'Test!',
|
|
};
|
|
await client.attachResource(noteById(noteId), resourceData);
|
|
|
|
return true;
|
|
}, {
|
|
noteId: selectOrCreateWriteableNote,
|
|
resourceId: () => context.randomId(),
|
|
});
|
|
|
|
addAction('moveNote', async ({ noteId, targetFolderId }) => {
|
|
const note = noteById(noteId);
|
|
const newParent = await folderByIdOrRandom(targetFolderId, {
|
|
filter: folder => folder.id !== note.parentId,
|
|
includeReadOnly: false,
|
|
});
|
|
if (!newParent) return false;
|
|
|
|
await client.moveItem(note.id, newParent.id);
|
|
return true;
|
|
}, {
|
|
noteId: selectOrCreateWriteableNote,
|
|
targetFolderId: undefinedId,
|
|
});
|
|
|
|
addAction('duplicateNote', async ({ id, newNoteId }) => {
|
|
const note = noteById(id);
|
|
|
|
await client.createNote({
|
|
...note,
|
|
id: newNoteId,
|
|
// When duplicated, notes are no longer published:
|
|
published: false,
|
|
});
|
|
return true;
|
|
}, {
|
|
id: selectOrCreateWriteableNote,
|
|
newNoteId: () => context.randomId(),
|
|
});
|
|
|
|
addAction('deleteNote', async ({ id }) => {
|
|
const validatedNote = noteById(id); // Ensure, e.g., that the note exists
|
|
|
|
await client.deleteNote(validatedNote.id);
|
|
return true;
|
|
}, { id: selectOrCreateWriteableNote });
|
|
|
|
const randomClientOnDifferentAccount = () => {
|
|
const other = clientPool.randomClient(c => !c.hasSameAccount(client));
|
|
if (!other) return undefined;
|
|
return clientPool.getClientId(other);
|
|
};
|
|
|
|
addAction('shareFolder', async ({ otherClientId, folderId, readOnly }) => {
|
|
// No suitable client?
|
|
if (otherClientId === undefined) return false;
|
|
|
|
const other = clientPool.clientById(otherClientId);
|
|
const target = await folderByIdOrRandom(folderId, {
|
|
filter: candidate => {
|
|
const isToplevel = !candidate.parentId;
|
|
const ownedByCurrent = candidate.ownedByEmail === client.email;
|
|
const alreadyShared = isToplevel && candidate.isSharedWith(other.email);
|
|
return isToplevel && ownedByCurrent && !alreadyShared;
|
|
},
|
|
includeReadOnly: true,
|
|
});
|
|
if (!target) return false;
|
|
|
|
await client.shareFolder(target.id, other, { readOnly });
|
|
return true;
|
|
}, {
|
|
otherClientId: randomClientOnDifferentAccount,
|
|
folderId: undefinedId,
|
|
readOnly: () => context.randInt(0, 2) === 1 && context.isJoplinCloud,
|
|
});
|
|
|
|
addAction('unshareFolder', async ({ folderId, clientId }) => {
|
|
const target = await folderByIdOrRandom(folderId, {
|
|
filter: candidate => {
|
|
return candidate.isRootSharedItem && candidate.ownedByEmail === client.email;
|
|
},
|
|
includeReadOnly: true,
|
|
});
|
|
if (!target) return false;
|
|
|
|
const recipientEmail = () => {
|
|
if (clientId !== undefined) {
|
|
if (clientId === 'all') {
|
|
return 'all';
|
|
}
|
|
|
|
const email = clientPool.clientById(clientId).email;
|
|
assert.ok(target.shareRecipients.includes(email), `Not shared with ${email}.`);
|
|
return email;
|
|
}
|
|
|
|
const recipientIndex = context.randInt(-1, target.shareRecipients.length);
|
|
if (recipientIndex === -1) return 'all';
|
|
|
|
const recipientEmail = target.shareRecipients[recipientIndex];
|
|
return recipientEmail;
|
|
};
|
|
|
|
|
|
const email = recipientEmail();
|
|
if (email === 'all') { // Completely remove the share
|
|
await client.deleteAssociatedShare(target.id);
|
|
} else {
|
|
const recipient = clientPool.clientsByEmail(email)[0];
|
|
assert.ok(recipient, `invalid state -- recipient ${recipientEmail} should exist`);
|
|
await client.removeFromShare(target.id, recipient);
|
|
}
|
|
return true;
|
|
}, {
|
|
folderId: undefinedId,
|
|
clientId: (): number|'all'|undefined => undefined,
|
|
});
|
|
|
|
addAction('deleteFolder', async ({ folderId }) => {
|
|
await client.deleteFolder(folderId);
|
|
return true;
|
|
}, {
|
|
folderId: selectOrCreateWriteableFolder,
|
|
});
|
|
|
|
addAction('moveFolderToToplevel', async ({ folderId }) => {
|
|
if (!folderId) return false;
|
|
|
|
await client.moveItem(folderId, '');
|
|
return true;
|
|
}, {
|
|
folderId: async () => (await client.randomFolder({
|
|
// Don't choose items that are already toplevel
|
|
filter: item => !!item.parentId,
|
|
includeReadOnly: false,
|
|
}))?.id,
|
|
});
|
|
|
|
addAction('moveFolderTo', async ({ sourceFolderId, newParentId }) => {
|
|
const target = await folderByIdOrRandom(sourceFolderId, {
|
|
// Don't move shared folders (should not be allowed by the GUI in the main apps).
|
|
filter: item => !item.isRootSharedItem,
|
|
includeReadOnly: false,
|
|
});
|
|
if (!target) return false;
|
|
|
|
const targetDescendants = new Set(await client.allFolderDescendants(target.id));
|
|
|
|
const newParent = await folderByIdOrRandom(newParentId, {
|
|
filter: (item) => {
|
|
// Avoid making the folder a child of itself
|
|
return !targetDescendants.has(item.id);
|
|
},
|
|
includeReadOnly: false,
|
|
});
|
|
if (!newParent) return false;
|
|
|
|
await client.moveItem(target.id, newParent.id);
|
|
return true;
|
|
}, {
|
|
sourceFolderId: undefinedId,
|
|
newParentId: undefinedId,
|
|
});
|
|
|
|
addAction('newClientOnSameAccount', async ({ welcomeNoteCount }) => {
|
|
logger.info(`Syncing a new client on the same account ${welcomeNoteCount > 0 ? `(with ${welcomeNoteCount} initial notes)` : ''}`);
|
|
const createClientInitialNotes = async (client: Client) => {
|
|
if (welcomeNoteCount === 0) return;
|
|
|
|
// Create a new folder. Usually, new clients have a default set of
|
|
// welcome notes when first syncing.
|
|
const parentFolder = await client.createRandomFolder({ parentId: '', quiet: false });
|
|
|
|
for (let i = 0; i < welcomeNoteCount; i++) {
|
|
await client.createRandomNote({ parentId: parentFolder.id });
|
|
}
|
|
};
|
|
|
|
await client.sync();
|
|
|
|
const other = await clientPool.newWithSameAccount(client);
|
|
await createClientInitialNotes(other);
|
|
|
|
// Sometimes, a delay is needed between client creation
|
|
// and initial sync. Retry the initial sync and the checkState
|
|
// on failure:
|
|
await retryWithCount(async () => {
|
|
await other.sync();
|
|
await other.checkState();
|
|
}, {
|
|
delayOnFailure: (count) => Second * count,
|
|
count: 3,
|
|
onFail: async ({ error, willRetry }) => {
|
|
logger.warn(
|
|
'other.sync/other.checkState failed with',
|
|
error,
|
|
willRetry ? 'retrying...' : '',
|
|
);
|
|
},
|
|
});
|
|
|
|
await client.sync();
|
|
return true;
|
|
}, {
|
|
welcomeNoteCount: () => context.randInt(0, 30),
|
|
});
|
|
|
|
addAction('removeClientsOnSameAccount', async () => {
|
|
const others = clientPool.othersWithSameAccount(client);
|
|
if (others.length === 0) return false;
|
|
|
|
for (const otherClient of others) {
|
|
assert.notEqual(otherClient, client);
|
|
await otherClient.close();
|
|
}
|
|
return true;
|
|
}, {});
|
|
|
|
addAction('createOrUpdateMany', async ({ count, updateProbability, deleteProbability, createProbability }) => {
|
|
await client.createOrUpdateMany(count, {
|
|
updateProbability,
|
|
deleteProbability,
|
|
createProbability,
|
|
});
|
|
return true;
|
|
}, {
|
|
count: () => context.randInt(1, 512),
|
|
createProbability: () => 0.3,
|
|
updateProbability: () => 0.5,
|
|
deleteProbability: () => 0.2,
|
|
});
|
|
|
|
addAction('publishNote', async ({ id }) => {
|
|
const note = noteById(id);
|
|
if (!note || note.published) return false;
|
|
|
|
await client.publishNote(note.id);
|
|
return true;
|
|
}, { id: selectOrCreateWriteableNote });
|
|
|
|
addAction('unpublishNote', async ({ id }) => {
|
|
if (!id) return false;
|
|
|
|
const note = noteById(id);
|
|
assert.ok(note.published, 'can only unpublish published notes');
|
|
|
|
await client.unpublishNote(note.id);
|
|
return true;
|
|
}, {
|
|
id: async () => {
|
|
const note = await client.randomNote({
|
|
includeReadOnly: false,
|
|
filter: (note) => note.published,
|
|
});
|
|
return note?.id;
|
|
},
|
|
});
|
|
|
|
addAction('sync', async () => {
|
|
await client.sync();
|
|
return true;
|
|
}, {});
|
|
|
|
addAction('comment', async ({ message }) => {
|
|
logger.info(`Action: Comment: ${JSON.stringify(message)}`);
|
|
return true;
|
|
}, { message: () => '' });
|
|
|
|
return { actions, schema, addAction };
|
|
};
|
|
|