文件
mynode/packages/tools/fuzzer/ActionRunner.ts
chen 301194963b
一些检查失败了
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
feat: Initial clean release of CHM
2026-11-04 11:29:39 +08:00

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 };
};