From 327964eeb1b641e18169fcfb3dcb43679c4362e2 Mon Sep 17 00:00:00 2001 From: FedericoAmura Date: Wed, 27 Nov 2024 16:36:37 +0100 Subject: [PATCH] feat: add automation package building blocks, including state machine, listeners, state and transitions --- package.json | 1 + packages/automation/.babelrc | 10 + packages/automation/.eslintrc.json | 18 ++ packages/automation/README.md | 3 + packages/automation/jest.config.ts | 16 ++ packages/automation/package.json | 32 ++++ packages/automation/project.json | 37 ++++ packages/automation/src/index.ts | 3 + .../src/lib/listeners/constant.spec.ts | 36 ++++ .../automation/src/lib/listeners/constant.ts | 17 ++ .../src/lib/listeners/evm-block.spec.ts | 56 ++++++ .../automation/src/lib/listeners/evm-block.ts | 27 +++ .../lib/listeners/evm-contract-event.spec.ts | 81 ++++++++ .../src/lib/listeners/evm-contract-event.ts | 57 ++++++ .../src/lib/listeners/fetch.spec.ts | 53 ++++++ .../automation/src/lib/listeners/fetch.ts | 49 +++++ .../automation/src/lib/listeners/index.ts | 7 + .../src/lib/listeners/interval.spec.ts | 63 +++++++ .../automation/src/lib/listeners/interval.ts | 21 +++ .../src/lib/listeners/listener.spec.ts | 67 +++++++ .../automation/src/lib/listeners/listener.ts | 65 +++++++ .../src/lib/listeners/timer.spec.ts | 55 ++++++ .../automation/src/lib/listeners/timer.ts | 25 +++ .../automation/src/lib/state-machine.spec.ts | 167 +++++++++++++++++ packages/automation/src/lib/state-machine.ts | 176 ++++++++++++++++++ packages/automation/src/lib/states/index.ts | 2 + .../src/lib/states/mint-pkp.spec.ts | 78 ++++++++ .../automation/src/lib/states/mint-pkp.ts | 32 ++++ .../automation/src/lib/states/state.spec.ts | 54 ++++++ packages/automation/src/lib/states/state.ts | 41 ++++ .../automation/src/lib/transitions/index.ts | 1 + .../src/lib/transitions/transition.spec.ts | 114 ++++++++++++ .../src/lib/transitions/transition.ts | 82 ++++++++ packages/automation/tsconfig.json | 25 +++ packages/automation/tsconfig.lib.json | 12 ++ packages/automation/tsconfig.spec.json | 11 ++ .../wrapped-keys-lit-actions/jest.config.ts | 2 +- .../tsconfig.lib.json | 2 +- packages/wrapped-keys/jest.config.ts | 2 +- yarn.lock | 51 ++++- 40 files changed, 1641 insertions(+), 10 deletions(-) create mode 100644 packages/automation/.babelrc create mode 100644 packages/automation/.eslintrc.json create mode 100644 packages/automation/README.md create mode 100644 packages/automation/jest.config.ts create mode 100644 packages/automation/package.json create mode 100644 packages/automation/project.json create mode 100644 packages/automation/src/index.ts create mode 100644 packages/automation/src/lib/listeners/constant.spec.ts create mode 100644 packages/automation/src/lib/listeners/constant.ts create mode 100644 packages/automation/src/lib/listeners/evm-block.spec.ts create mode 100644 packages/automation/src/lib/listeners/evm-block.ts create mode 100644 packages/automation/src/lib/listeners/evm-contract-event.spec.ts create mode 100644 packages/automation/src/lib/listeners/evm-contract-event.ts create mode 100644 packages/automation/src/lib/listeners/fetch.spec.ts create mode 100644 packages/automation/src/lib/listeners/fetch.ts create mode 100644 packages/automation/src/lib/listeners/index.ts create mode 100644 packages/automation/src/lib/listeners/interval.spec.ts create mode 100644 packages/automation/src/lib/listeners/interval.ts create mode 100644 packages/automation/src/lib/listeners/listener.spec.ts create mode 100644 packages/automation/src/lib/listeners/listener.ts create mode 100644 packages/automation/src/lib/listeners/timer.spec.ts create mode 100644 packages/automation/src/lib/listeners/timer.ts create mode 100644 packages/automation/src/lib/state-machine.spec.ts create mode 100644 packages/automation/src/lib/state-machine.ts create mode 100644 packages/automation/src/lib/states/index.ts create mode 100644 packages/automation/src/lib/states/mint-pkp.spec.ts create mode 100644 packages/automation/src/lib/states/mint-pkp.ts create mode 100644 packages/automation/src/lib/states/state.spec.ts create mode 100644 packages/automation/src/lib/states/state.ts create mode 100644 packages/automation/src/lib/transitions/index.ts create mode 100644 packages/automation/src/lib/transitions/transition.spec.ts create mode 100644 packages/automation/src/lib/transitions/transition.ts create mode 100644 packages/automation/tsconfig.json create mode 100644 packages/automation/tsconfig.lib.json create mode 100644 packages/automation/tsconfig.spec.json diff --git a/package.json b/package.json index 5499153639..109120e0f3 100644 --- a/package.json +++ b/package.json @@ -86,6 +86,7 @@ "@nx/web": "17.3.0", "@solana/web3.js": "^1.95.3", "@types/depd": "^1.1.36", + "@types/events": "^3.0.3", "@types/jest": "27.4.1", "@types/node": "18.19.18", "@types/secp256k1": "^4.0.6", diff --git a/packages/automation/.babelrc b/packages/automation/.babelrc new file mode 100644 index 0000000000..158083d278 --- /dev/null +++ b/packages/automation/.babelrc @@ -0,0 +1,10 @@ +{ + "presets": [ + [ + "@nx/web/babel", + { + "useBuiltIns": "usage" + } + ] + ] +} diff --git a/packages/automation/.eslintrc.json b/packages/automation/.eslintrc.json new file mode 100644 index 0000000000..9d9c0db55b --- /dev/null +++ b/packages/automation/.eslintrc.json @@ -0,0 +1,18 @@ +{ + "extends": ["../../.eslintrc.json"], + "ignorePatterns": ["!**/*"], + "overrides": [ + { + "files": ["*.ts", "*.tsx", "*.js", "*.jsx"], + "rules": {} + }, + { + "files": ["*.ts", "*.tsx"], + "rules": {} + }, + { + "files": ["*.js", "*.jsx"], + "rules": {} + } + ] +} diff --git a/packages/automation/README.md b/packages/automation/README.md new file mode 100644 index 0000000000..f6f64c5793 --- /dev/null +++ b/packages/automation/README.md @@ -0,0 +1,3 @@ +# Quick Start + +This submodule is used to automate different actions using the Lit Protocol network or other useful events providing listening and responding abilities based on state machines. diff --git a/packages/automation/jest.config.ts b/packages/automation/jest.config.ts new file mode 100644 index 0000000000..46f114b5e6 --- /dev/null +++ b/packages/automation/jest.config.ts @@ -0,0 +1,16 @@ +/* eslint-disable */ +export default { + displayName: 'types', + preset: '../../jest.preset.js', + globals: { + 'ts-jest': { + tsconfig: '/tsconfig.spec.json', + }, + }, + transform: { + '^.+\\.[t]s$': 'ts-jest', + }, + moduleFileExtensions: ['ts', 'js', 'html'], + coverageDirectory: '../../coverage/packages/automation', + setupFilesAfterEnv: ['../../jest.setup.js'], +}; diff --git a/packages/automation/package.json b/packages/automation/package.json new file mode 100644 index 0000000000..d2191c6cdc --- /dev/null +++ b/packages/automation/package.json @@ -0,0 +1,32 @@ +{ + "name": "@lit-protocol/automation", + "type": "commonjs", + "license": "MIT", + "homepage": "https://github.com/Lit-Protocol/js-sdk", + "repository": { + "type": "git", + "url": "https://github.com/LIT-Protocol/js-sdk" + }, + "keywords": [ + "library" + ], + "bugs": { + "url": "https://github.com/LIT-Protocol/js-sdk/issues" + }, + "publishConfig": { + "access": "public", + "directory": "../../dist/packages/automation" + }, + "tags": [ + "universal" + ], + "buildOptions": { + "genReact": false + }, + "scripts": { + "generate-lit-actions": "yarn node ./esbuild.config.js" + }, + "version": "7.0.0", + "main": "./dist/src/index.js", + "typings": "./dist/src/index.d.ts" +} diff --git a/packages/automation/project.json b/packages/automation/project.json new file mode 100644 index 0000000000..52dc6246c0 --- /dev/null +++ b/packages/automation/project.json @@ -0,0 +1,37 @@ +{ + "name": "automation", + "$schema": "../../node_modules/nx/schemas/project-schema.json", + "sourceRoot": "packages/automation/src", + "projectType": "library", + "targets": { + "build": { + "cache": false, + "executor": "@nx/js:tsc", + "outputs": ["{options.outputPath}"], + "options": { + "outputPath": "dist/packages/automation", + "main": "packages/automation/src/index.ts", + "tsConfig": "packages/automation/tsconfig.lib.json", + "assets": ["packages/automation/*.md"], + "updateBuildableProjectDepsInPackageJson": true + }, + "dependsOn": ["^build"] + }, + "lint": { + "executor": "@nx/linter:eslint", + "outputs": ["{options.outputFile}"], + "options": { + "lintFilePatterns": ["packages/automation/**/*.ts"] + } + }, + "test": { + "executor": "@nx/jest:jest", + "outputs": ["{workspaceRoot}/coverage/packages/automation"], + "options": { + "jestConfig": "packages/automation/jest.config.ts", + "passWithNoTests": true + } + } + }, + "tags": [] +} diff --git a/packages/automation/src/index.ts b/packages/automation/src/index.ts new file mode 100644 index 0000000000..1feb0e620e --- /dev/null +++ b/packages/automation/src/index.ts @@ -0,0 +1,3 @@ +import { StateMachine } from './lib/state-machine'; + +export { StateMachine }; diff --git a/packages/automation/src/lib/listeners/constant.spec.ts b/packages/automation/src/lib/listeners/constant.spec.ts new file mode 100644 index 0000000000..e8c6cee123 --- /dev/null +++ b/packages/automation/src/lib/listeners/constant.spec.ts @@ -0,0 +1,36 @@ +import { ConstantListener } from './constant'; + +describe('ConstantListener', () => { + let constantListener: ConstantListener; + const valueToEmit = 42; + + beforeEach(() => { + constantListener = new ConstantListener(valueToEmit); + }); + + it('should emit the constant value immediately when started', async () => { + const callback = jest.fn(); + constantListener.onStateChange(callback); + + await constantListener.start(); + + // Advance event loop + await new Promise((resolve) => setTimeout(resolve, 0)); + + expect(callback).toHaveBeenCalledWith(valueToEmit); + }); + + it('should not emit any value after being stopped', async () => { + const callback = jest.fn(); + constantListener.onStateChange(callback); + + await constantListener.start(); + await constantListener.stop(); + + // Advance event loop + await new Promise((resolve) => setTimeout(resolve, 0)); + + // Ensure no additional calls were made after stop + expect(callback).toHaveBeenCalledTimes(1); + }); +}); diff --git a/packages/automation/src/lib/listeners/constant.ts b/packages/automation/src/lib/listeners/constant.ts new file mode 100644 index 0000000000..9565b8f19c --- /dev/null +++ b/packages/automation/src/lib/listeners/constant.ts @@ -0,0 +1,17 @@ +import { Listener } from './listener'; + +/** + * A simple listener that emits a constant value immediately when started + */ +export class ConstantListener extends Listener { + constructor(private value: T) { + super({ + start: async () => { + // Emit value on next tick simulating a state change and respecting event architecture + setTimeout(() => { + this.emit(this.value); + }, 0); + }, + }); + } +} diff --git a/packages/automation/src/lib/listeners/evm-block.spec.ts b/packages/automation/src/lib/listeners/evm-block.spec.ts new file mode 100644 index 0000000000..44619c9ee1 --- /dev/null +++ b/packages/automation/src/lib/listeners/evm-block.spec.ts @@ -0,0 +1,56 @@ +import { ethers } from 'ethers'; + +import { EVMBlockListener } from './evm-block'; + +jest.mock('ethers'); + +describe('EVMBlockListener', () => { + let evmBlockListener: EVMBlockListener; + let providerMock: jest.Mocked; + + beforeEach(() => { + providerMock = { + on: jest.fn(), + removeAllListeners: jest.fn(), + getBlock: jest.fn().mockResolvedValue({ number: 123, hash: '0xabc' }), + } as unknown as jest.Mocked; + + ( + ethers.providers.JsonRpcProvider as unknown as jest.Mock + ).mockImplementation(() => providerMock); + + evmBlockListener = new EVMBlockListener('http://example-rpc-url.com'); + }); + + afterEach(async () => { + await evmBlockListener.stop(); + jest.clearAllMocks(); + }); + + it('should start listening to block events', async () => { + await evmBlockListener.start(); + + expect(providerMock.on).toHaveBeenCalledWith('block', expect.any(Function)); + }); + + it('should emit block data on block event', async () => { + const callback = jest.fn(); + evmBlockListener.onStateChange(callback); + + await evmBlockListener.start(); + + // Simulate block event + const blockEventCallback = providerMock.on.mock.calls[0][1]; + await blockEventCallback(123); + + expect(providerMock.getBlock).toHaveBeenCalledWith(123); + expect(callback).toHaveBeenCalledWith({ number: 123, hash: '0xabc' }); + }); + + it('should stop listening to block events', async () => { + await evmBlockListener.start(); + await evmBlockListener.stop(); + + expect(providerMock.removeAllListeners).toHaveBeenCalledWith('block'); + }); +}); diff --git a/packages/automation/src/lib/listeners/evm-block.ts b/packages/automation/src/lib/listeners/evm-block.ts new file mode 100644 index 0000000000..0a671b2c8f --- /dev/null +++ b/packages/automation/src/lib/listeners/evm-block.ts @@ -0,0 +1,27 @@ +import { ethers } from 'ethers'; + +import { LIT_EVM_CHAINS } from '@lit-protocol/constants'; + +import { Listener } from './listener'; + +export type BlockData = ethers.providers.Block; + +export class EVMBlockListener extends Listener { + constructor(rpcUrl: string = LIT_EVM_CHAINS['ethereum'].rpcUrls[0]) { + const provider = new ethers.providers.JsonRpcProvider(rpcUrl); + + super({ + start: async () => { + provider.on('block', async (blockNumber) => { + const block = await provider.getBlock(blockNumber); + if (block) { + this.emit(block); + } + }); + }, + stop: async () => { + provider.removeAllListeners('block'); + }, + }); + } +} diff --git a/packages/automation/src/lib/listeners/evm-contract-event.spec.ts b/packages/automation/src/lib/listeners/evm-contract-event.spec.ts new file mode 100644 index 0000000000..c4191ccedf --- /dev/null +++ b/packages/automation/src/lib/listeners/evm-contract-event.spec.ts @@ -0,0 +1,81 @@ +import { ethers } from 'ethers'; + +import { + EVMContractEventListener, + ContractInfo, + EventInfo, +} from './evm-contract-event'; + +jest.mock('ethers'); + +describe('EVMContractEventListener', () => { + let evmContractEventListener: EVMContractEventListener; + let contractMock: jest.Mocked; + const rpcUrl = 'http://example-rpc-url.com'; + const contractInfo: ContractInfo = { + address: '0x123', + abi: [], + }; + const eventInfo: EventInfo = { + name: 'TestEvent', + }; + + beforeEach(() => { + contractMock = { + on: jest.fn(), + removeAllListeners: jest.fn(), + filters: { + TestEvent: jest.fn().mockReturnValue({}), + }, + } as unknown as jest.Mocked; + + (ethers.Contract as unknown as jest.Mock).mockImplementation( + () => contractMock + ); + + evmContractEventListener = new EVMContractEventListener( + rpcUrl, + contractInfo, + eventInfo + ); + }); + + afterEach(async () => { + await evmContractEventListener.stop(); + jest.clearAllMocks(); + }); + + it('should start listening to contract events', async () => { + await evmContractEventListener.start(); + + expect(contractMock.on).toHaveBeenCalledWith({}, expect.any(Function)); + }); + + it('should emit event data on contract event', async () => { + const callback = jest.fn(); + evmContractEventListener.onStateChange(callback); + + await evmContractEventListener.start(); + + // Simulate contract event + const eventCallback = contractMock.on.mock.calls[0][1]; + const mockEvent = { blockNumber: 123, transactionHash: '0xabc' }; + eventCallback('arg1', 'arg2', mockEvent); + + expect(callback).toHaveBeenCalledWith({ + event: mockEvent, + args: ['arg1', 'arg2'], + blockNumber: 123, + transactionHash: '0xabc', + }); + }); + + it('should stop listening to contract events', async () => { + await evmContractEventListener.start(); + await evmContractEventListener.stop(); + + expect(contractMock.removeAllListeners).toHaveBeenCalledWith( + eventInfo.name + ); + }); +}); diff --git a/packages/automation/src/lib/listeners/evm-contract-event.ts b/packages/automation/src/lib/listeners/evm-contract-event.ts new file mode 100644 index 0000000000..43040885dd --- /dev/null +++ b/packages/automation/src/lib/listeners/evm-contract-event.ts @@ -0,0 +1,57 @@ +import { ethers } from 'ethers'; +import { Listener } from './listener'; + +export type ContractEventData = { + event: ethers.Event; + args: any[]; + blockNumber: number; + transactionHash: string; +}; + +export interface ContractInfo { + address: string; + abi: ethers.ContractInterface; +} + +export interface EventInfo { + name: string; + filter?: any[]; +} + +export class EVMContractEventListener extends Listener { + constructor( + rpcUrl: string, + contractInfo: ContractInfo, + eventInfo: EventInfo + ) { + const provider = new ethers.providers.JsonRpcProvider(rpcUrl); + const contract = new ethers.Contract( + contractInfo.address, + contractInfo.abi, + provider + ); + + super({ + start: async () => { + const eventFilter = contract.filters[eventInfo.name]( + ...(eventInfo.filter || []) + ); + + contract.on(eventFilter, (...args) => { + const event = args[args.length - 1] as ethers.Event; + const eventArgs = args.slice(0, -1); + + this.emit({ + event, + args: eventArgs, + blockNumber: event.blockNumber, + transactionHash: event.transactionHash, + }); + }); + }, + stop: async () => { + contract.removeAllListeners(eventInfo.name); + }, + }); + } +} diff --git a/packages/automation/src/lib/listeners/fetch.spec.ts b/packages/automation/src/lib/listeners/fetch.spec.ts new file mode 100644 index 0000000000..ed0098ff01 --- /dev/null +++ b/packages/automation/src/lib/listeners/fetch.spec.ts @@ -0,0 +1,53 @@ +import { FetchListener } from './fetch'; + +describe('FetchListener', () => { + let fetchListener: FetchListener; + let fetchMock: jest.Mock; + + beforeEach(() => { + jest.useFakeTimers(); + fetchMock = jest.fn().mockResolvedValue({ + json: jest.fn().mockResolvedValue({ data: { value: 42 } }), + }); + global.fetch = fetchMock; + + fetchListener = new FetchListener('http://example.com', { + fetchConfig: {}, + listenerConfig: { + pollInterval: 1000, + pathResponse: 'data.value', + }, + }); + }); + + afterEach(async () => { + await fetchListener.stop(); + jest.clearAllMocks(); + jest.useRealTimers(); + }); + + it('should fetch data and emit the correct value', async () => { + let callbackCalled: () => void; + const callbackPromise = new Promise(resolve => callbackCalled = resolve); + + const callback = jest.fn(async () => { + callbackCalled(); + }); + fetchListener.onStateChange(callback); + + await fetchListener.start(); + jest.advanceTimersByTime(1000); + await callbackPromise; + + expect(fetchMock).toHaveBeenCalledWith('http://example.com', {}); + expect(callback).toHaveBeenCalledWith(42); + }); + + it('should stop polling when stopped', async () => { + await fetchListener.start(); + await fetchListener.stop(); + + jest.advanceTimersByTime(2000); + expect(fetchMock).toHaveBeenCalledTimes(0); + }); +}); diff --git a/packages/automation/src/lib/listeners/fetch.ts b/packages/automation/src/lib/listeners/fetch.ts new file mode 100644 index 0000000000..681cd94bba --- /dev/null +++ b/packages/automation/src/lib/listeners/fetch.ts @@ -0,0 +1,49 @@ +import { Listener } from './listener'; + +interface FetchListenerConfig { + fetchConfig?: RequestInit; + listenerConfig?: { + pollInterval?: number; + pathResponse?: string; + }; +} + +export class FetchListener extends Listener { + private readonly url: string; + private config: FetchListenerConfig; + private intervalId: ReturnType | null = null; + + constructor(url: string, config: FetchListenerConfig = {}) { + super({ + start: async () => { + const { pollInterval = 1000, pathResponse = '' } = + this.config.listenerConfig ?? {}; + + this.intervalId = setInterval(async () => { + try { + const response = await fetch(this.url, this.config.fetchConfig); + const data = await response.json(); + const value = pathResponse + ? pathResponse + .split('.') + .reduce((acc, part) => acc && acc[part], data) + : data; + if (value !== undefined) { + this.emit(value); + } + } catch (error) { + console.error('FetchListener error:', error); + } + }, pollInterval); + }, + stop: async () => { + if (this.intervalId) { + clearInterval(this.intervalId); + this.intervalId = null; + } + }, + }); + this.url = url; + this.config = config; + } +} diff --git a/packages/automation/src/lib/listeners/index.ts b/packages/automation/src/lib/listeners/index.ts new file mode 100644 index 0000000000..6f6e9c0e9e --- /dev/null +++ b/packages/automation/src/lib/listeners/index.ts @@ -0,0 +1,7 @@ +export * from './constant'; +export * from './evm-block'; +export * from './evm-contract-event'; +export * from './fetch'; +export * from './interval'; +export * from './listener'; +export * from './timer'; diff --git a/packages/automation/src/lib/listeners/interval.spec.ts b/packages/automation/src/lib/listeners/interval.spec.ts new file mode 100644 index 0000000000..47ed35616b --- /dev/null +++ b/packages/automation/src/lib/listeners/interval.spec.ts @@ -0,0 +1,63 @@ +import { IntervalListener } from './interval'; + +describe('IntervalListener', () => { + let intervalListener: IntervalListener; + let callback: jest.Mock; + const interval = 1000; + + beforeEach(() => { + jest.useFakeTimers(); + callback = jest.fn().mockResolvedValue(42); + intervalListener = new IntervalListener(callback, interval); + }); + + afterEach(async () => { + await intervalListener.stop(); + jest.clearAllMocks(); + jest.useRealTimers(); + }); + + it('should call the callback at specified intervals', async () => { + let firstStateCallbackResolve: () => void; + const firstStateCallbackPromise = new Promise( + (resolve) => (firstStateCallbackResolve = resolve) + ); + const firstStateCallbackMock = jest.fn(async () => + firstStateCallbackResolve() + ); + intervalListener.onStateChange(firstStateCallbackMock); + + await intervalListener.start(); + + jest.advanceTimersByTime(interval); + await firstStateCallbackPromise; + + expect(callback).toHaveBeenCalledTimes(1); + expect(firstStateCallbackMock).toHaveBeenCalledWith(42); + + let secondStateCallbackResolve: () => void; + const secondStateCallbackPromise = new Promise( + (resolve) => (secondStateCallbackResolve = resolve) + ); + const secondStateCallbackMock = jest.fn(async () => + secondStateCallbackResolve() + ); + intervalListener.onStateChange(secondStateCallbackMock); + + jest.advanceTimersByTime(interval); + await secondStateCallbackPromise; + + expect(callback).toHaveBeenCalledTimes(2); + expect(secondStateCallbackMock).toHaveBeenCalledWith(42); + }); + + it('should stop calling the callback when stopped', async () => { + await intervalListener.start(); + await intervalListener.stop(); + + jest.advanceTimersByTime(interval * 2); + await Promise.resolve(); + + expect(callback).toHaveBeenCalledTimes(0); + }); +}); diff --git a/packages/automation/src/lib/listeners/interval.ts b/packages/automation/src/lib/listeners/interval.ts new file mode 100644 index 0000000000..e012961198 --- /dev/null +++ b/packages/automation/src/lib/listeners/interval.ts @@ -0,0 +1,21 @@ +import { Listener } from './listener'; + +export class IntervalListener extends Listener { + private intervalId?: ReturnType; + + constructor(callback: () => Promise, interval = 1000) { + super({ + start: async () => { + this.intervalId = setInterval(async () => { + const value = await callback(); + this.emit(value); + }, interval); + }, + stop: async () => { + if (this.intervalId) { + clearInterval(this.intervalId); + } + }, + }); + } +} diff --git a/packages/automation/src/lib/listeners/listener.spec.ts b/packages/automation/src/lib/listeners/listener.spec.ts new file mode 100644 index 0000000000..d0713d13db --- /dev/null +++ b/packages/automation/src/lib/listeners/listener.spec.ts @@ -0,0 +1,67 @@ +import { Listener } from './listener'; + +describe('Listener', () => { + let listener: Listener; + let setup: jest.Mock; + let teardown: jest.Mock; + + beforeEach(() => { + setup = jest.fn(); + teardown = jest.fn(); + listener = new (class extends Listener { + constructor() { + super({ + start: setup, + stop: teardown, + }); + } + + // Expose emit for testing + public testEmit(value: number) { + this.emit(value); + } + })(); + }); + + it('should call setup on start', async () => { + await listener.start(); + expect(setup).toHaveBeenCalled(); + }); + + it('should call teardown on stop', async () => { + await listener.stop(); + expect(teardown).toHaveBeenCalled(); + }); + + it('should notify listeners of state changes with the new value', () => { + const callback = jest.fn(); + listener.onStateChange(callback); + (listener as any).testEmit(5); + expect(callback).toHaveBeenCalledWith(5); + }); + + it('should not remove listeners on stop', async () => { + const callback = jest.fn(); + listener.onStateChange(callback); + await listener.stop(); + (listener as any).testEmit(5); + expect(callback).toHaveBeenCalled(); + }); + + it('should replace previous callback when registering a new one', () => { + const callback1 = jest.fn(); + const callback2 = jest.fn(); + + // Register first callback + listener.onStateChange(callback1); + (listener as any).testEmit(5); + expect(callback1).toHaveBeenCalledWith(5); + expect(callback2).not.toHaveBeenCalled(); + + // Register second callback - should replace the first one + listener.onStateChange(callback2); + (listener as any).testEmit(10); + expect(callback1).toHaveBeenCalledTimes(1); // Should not receive the second emit + expect(callback2).toHaveBeenCalledWith(10); + }); +}); diff --git a/packages/automation/src/lib/listeners/listener.ts b/packages/automation/src/lib/listeners/listener.ts new file mode 100644 index 0000000000..694de9a3d3 --- /dev/null +++ b/packages/automation/src/lib/listeners/listener.ts @@ -0,0 +1,65 @@ +import { EventEmitter } from 'events'; + +export interface ListenerParams { + start?: () => Promise; + stop?: () => Promise; +} + +/** + * A Listener class that manages event listeners for state changes. + * @template T The type of the value being listened to. Defaults to unknown. + */ +export class Listener { + private emitter = new EventEmitter(); + private currentCallback: ((value: T) => Promise) | null = null; + + /** + * The start function called when all listeners are started. + */ + public start: () => Promise; + + /** + * The stop function called when all listeners are stopped. + */ + public stop: () => Promise; + + /** + * Constructor for the Listener class. + * @param params The parameters object containing start and stop functions. + */ + constructor({ + start = async () => {}, + stop = async () => {}, + }: ListenerParams = {}) { + this.start = start; + this.stop = stop; + } + + /** + * Removes all listeners from the emitter. + */ + removeAllListeners() { + this.emitter.removeAllListeners(); + } + + /** + * Registers a callback to be called when the state changes. + * If a callback was previously registered, it will be replaced with the new one. + * @param callback The function to call with the new state value. + */ + onStateChange(callback: (value: T) => Promise) { + if (this.currentCallback) { + this.emitter.removeListener('stateChange', this.currentCallback); + } + this.currentCallback = callback; + this.emitter.on('stateChange', callback); + } + + /** + * Emits a state change event with the given value. + * @param value The state value to emit. + */ + protected emit(value: T) { + this.emitter.emit('stateChange', value); + } +} diff --git a/packages/automation/src/lib/listeners/timer.spec.ts b/packages/automation/src/lib/listeners/timer.spec.ts new file mode 100644 index 0000000000..84a79a5ec3 --- /dev/null +++ b/packages/automation/src/lib/listeners/timer.spec.ts @@ -0,0 +1,55 @@ +import { TimerListener } from './timer'; + +describe('TimerListener', () => { + let timerListener: TimerListener; + const interval = 1000; + const offset = 0; + const step = 1; + + beforeEach(() => { + jest.useFakeTimers(); + timerListener = new TimerListener(interval, offset, step); + }); + + afterEach(async () => { + await timerListener.stop(); + jest.clearAllMocks(); + jest.useRealTimers(); + }); + + it('should emit incremented values at specified intervals', async () => { + const callback = jest.fn(); + timerListener.onStateChange(callback); + + await timerListener.start(); + + jest.advanceTimersByTime(interval); + await Promise.resolve(); + + expect(callback).toHaveBeenCalledWith(1); + + jest.advanceTimersByTime(interval); + await Promise.resolve(); + + expect(callback).toHaveBeenCalledWith(2); + }); + + it('should reset count to offset when stopped', async () => { + const callback = jest.fn(); + timerListener.onStateChange(callback); + + await timerListener.start(); + + jest.advanceTimersByTime(interval * 3); + await Promise.resolve(); + + expect(callback).toHaveBeenCalledWith(3); + + await timerListener.stop(); + + jest.advanceTimersByTime(interval); + await Promise.resolve(); + + expect(callback).toHaveBeenCalledTimes(3); // No additional calls after stop + }); +}); diff --git a/packages/automation/src/lib/listeners/timer.ts b/packages/automation/src/lib/listeners/timer.ts new file mode 100644 index 0000000000..5e06e7823e --- /dev/null +++ b/packages/automation/src/lib/listeners/timer.ts @@ -0,0 +1,25 @@ +import { Listener } from './listener'; + +export class TimerListener extends Listener { + private intervalId?: ReturnType; + private count = 0; + + constructor(interval = 1000, offset = 0, step = 1) { + super({ + start: async () => { + this.intervalId = setInterval(() => { + this.count += step; + this.emit(this.count); + }, interval); + }, + stop: async () => { + this.count = offset; + if (this.intervalId) { + clearInterval(this.intervalId); + } + }, + }); + + this.count = offset; + } +} diff --git a/packages/automation/src/lib/state-machine.spec.ts b/packages/automation/src/lib/state-machine.spec.ts new file mode 100644 index 0000000000..ff7410b9e1 --- /dev/null +++ b/packages/automation/src/lib/state-machine.spec.ts @@ -0,0 +1,167 @@ +import { StateMachine } from './state-machine'; +import { Listener } from './listeners'; + +describe('StateMachine', () => { + let stateMachine: StateMachine; + let listener: Listener; + let check: jest.Mock; + let onMatch: jest.Mock; + let callOrder: string[]; + + beforeEach(() => { + callOrder = []; + stateMachine = new StateMachine(); + listener = new Listener({ + start: async () => {}, + stop: async () => {}, + }); + check = jest.fn(() => true); + onMatch = jest.fn(); + + stateMachine.addState({ + key: 'A', + onEnter: async () => { + callOrder.push('enter A'); + }, + onExit: async () => { + callOrder.push('exit A'); + }, + }); + stateMachine.addState({ + key: 'B', + onEnter: async () => { + callOrder.push('enter B'); + }, + onExit: async () => { + callOrder.push('exit B'); + }, + }); + }); + + it('should add states and transitions correctly', () => { + stateMachine.addTransition({ + fromState: 'A', + toState: 'B', + listeners: [listener], + check, + onMatch, + }); + expect(() => + stateMachine.addTransition({ + fromState: 'A', + toState: 'B', + listeners: [listener], + check, + onMatch, + }) + ).not.toThrow(); + }); + + it('should start the machine and trigger transitions in the correct order', async () => { + stateMachine.addTransition({ + fromState: 'A', + toState: 'B', + listeners: [listener], + check, + onMatch, + }); + await stateMachine.startMachine('A'); + + // Simulate transition action + await stateMachine['transitionTo']('B'); + + // Check the order of calls + await expect(callOrder).toEqual(['enter A', 'exit A', 'enter B']); + }); + + it('should not allow duplicate transitions with the same from-to combination', () => { + const newCheck = jest.fn(async () => false); + const newOnMatch = jest.fn(); + stateMachine.addTransition({ + fromState: 'A', + toState: 'B', + listeners: [listener], + check, + onMatch, + }); + stateMachine.addTransition({ + fromState: 'A', + toState: 'B', + listeners: [listener], + check: newCheck, + onMatch: newOnMatch, + }); + + const transitions = stateMachine['transitions'].get('A'); + const transition = transitions?.get('B'); + expect(transition).toBeDefined(); + expect(transition?.['check']).toBe(newCheck); + }); + + describe('stopMachine', () => { + it('should do nothing if no current state', async () => { + await stateMachine.stopMachine(); + expect(callOrder).toEqual([]); + }); + + it('should cleanup current state and transitions', async () => { + stateMachine.addTransition({ + fromState: 'A', + toState: 'B', + listeners: [listener], + check, + onMatch, + }); + + await stateMachine.startMachine('A'); + expect(callOrder).toEqual(['enter A']); + + await stateMachine.stopMachine(); + + expect(callOrder).toEqual(['enter A', 'exit A']); + }); + + it('should call onStop callback when provided', async () => { + const onStop = jest.fn(); + stateMachine.addTransition({ + fromState: 'A', + toState: 'B', + listeners: [listener], + check, + onMatch, + }); + + await stateMachine.startMachine('A', onStop); + expect(callOrder).toEqual(['enter A']); + + await stateMachine.stopMachine(); + + expect(onStop).toHaveBeenCalled(); + expect(callOrder).toEqual(['enter A', 'exit A']); + }); + + it('should handle errors in onStop callback', async () => { + const errorMessage = 'onStop error'; + const onStop = jest.fn().mockRejectedValue(new Error(errorMessage)); + + await stateMachine.startMachine('A', onStop); + await expect(stateMachine.stopMachine()).rejects.toThrow(errorMessage); + }); + + it('should handle errors during cleanup', async () => { + const errorStateMachine = new StateMachine(); + const errorMessage = 'Exit error'; + errorStateMachine.addState({ + key: 'error', + onExit: async () => { + throw new Error(errorMessage); + }, + }); + await errorStateMachine.startMachine('error'); + + await expect(errorStateMachine.stopMachine()).rejects.toThrow( + errorMessage + ); + }); + }); +}); diff --git a/packages/automation/src/lib/state-machine.ts b/packages/automation/src/lib/state-machine.ts new file mode 100644 index 0000000000..c85dcd816d --- /dev/null +++ b/packages/automation/src/lib/state-machine.ts @@ -0,0 +1,176 @@ +import { State, StateParams } from './states'; +import { Transition, BaseTransitionParams } from './transitions'; + +export interface BaseStateMachineParams { + debug?: boolean; +} + +export interface TransitionParams + extends Omit, + Partial> { + fromState: string; + toState: string; +} + +type MachineStatus = 'running' | 'stopped'; + +/** + * A StateMachine class that manages states and transitions between them. + */ +export class StateMachine { + private status: MachineStatus = 'stopped'; + private states = new Map(); + private transitions = new Map>(); + private currentState?: State; + private onStopCallback?: () => Promise; + private debug = false; + + constructor(params: BaseStateMachineParams = {}) { + this.debug = params.debug ?? false; + } + + get isRunning() { + return this.status === 'running'; + } + + /** + * Adds a state to the state machine. + * @param params The parameters for the state. + */ + addState(params: StateParams) { + const state = new State(params); + this.states.set(state.key, state); + if (!this.transitions.has(state.key)) { + this.transitions.set(state.key, new Map()); + } + } + + /** + * Adds a transition between two states. + * @param params The parameters for the transition. + */ + addTransition({ + fromState, + toState, + listeners, + check, + onMatch, + onMismatch, + }: TransitionParams) { + if (!this.states.has(fromState)) { + throw new Error(`Source state ${fromState} not found`); + } + if (!this.states.has(toState)) { + throw new Error(`Target state ${toState} not found`); + } + + const transitioningOnMatch = async (values: (unknown | undefined)[]) => { + await onMatch?.(values); + await this.transitionTo(toState); + }; + + const transition = new Transition({ + listeners, + check, + onMatch: transitioningOnMatch, + onMismatch, + }); + + const stateTransitions = + this.transitions.get(fromState) ?? new Map(); + stateTransitions.set(toState, transition); + this.transitions.set(fromState, stateTransitions); + } + + /** + * Starts the state machine with the given initial state. + * @param initialState The key of the initial state. + * @param onStop Optional callback to execute when the machine is stopped. + */ + async startMachine(initialState: string, onStop?: () => Promise) { + this.debug && console.log('Starting state machine...'); + + this.onStopCallback = onStop; + await this.enterState(initialState); + this.status = 'running'; + + this.debug && console.log('State machine started'); + } + + /** + * Stops the state machine by exiting the current state and not moving to another one. + */ + async stopMachine() { + this.debug && console.log('Stopping state machine...'); + + await this.exitCurrentState(); + await this.onStopCallback?.(); + this.status = 'stopped'; + + this.debug && console.log('State machine stopped'); + } + + /** + * Stops listening on the current state's transitions and exits the current state. + */ + private async exitCurrentState() { + if (!this.isRunning) { + return; + } + + this.debug && console.log('exitCurrentState', this.currentState?.key); + + const currentTransitions = + this.transitions.get(this.currentState?.key ?? '') ?? + new Map(); + await Promise.all( + Array.from(currentTransitions.values()).map((t) => t.stopListening()) + ); + await this.currentState?.exit(); + this.currentState = undefined; + } + + /** + * Moves to a new state. + * @param stateKey The key of the new state. + */ + private async enterState(stateKey: string) { + const state = this.states.get(stateKey); + if (!state) { + throw new Error(`State ${stateKey} not found`); + } + this.debug && console.log('enterState', state.key); + await state.enter(); + const nextTransitions = + this.transitions.get(state.key) ?? new Map(); + await Promise.all( + Array.from(nextTransitions.values()).map((t) => t.startListening()) + ); + this.currentState = state; + } + + /** + * Triggers a transition to a new state. + * @param stateKey The key of the target state. + */ + private async transitionTo(stateKey: string) { + const nextState = this.states.get(stateKey); + + if (!nextState) { + throw new Error(`State ${stateKey} not found`); + } + if (this.currentState === nextState) { + console.warn(`State ${stateKey} is already active. Skipping transition.`); + return; + } + + try { + // Machine consumer can call stopMachine() while we are in the middle of a transition + this.isRunning && (await this.exitCurrentState()); + this.isRunning && (await this.enterState(stateKey)); + } catch (e) { + this.currentState = undefined; + throw new Error(`Could not enter state ${stateKey}`); + } + } +} diff --git a/packages/automation/src/lib/states/index.ts b/packages/automation/src/lib/states/index.ts new file mode 100644 index 0000000000..a7b5e19c3b --- /dev/null +++ b/packages/automation/src/lib/states/index.ts @@ -0,0 +1,2 @@ +export * from './mint-pkp'; +export * from './state'; diff --git a/packages/automation/src/lib/states/mint-pkp.spec.ts b/packages/automation/src/lib/states/mint-pkp.spec.ts new file mode 100644 index 0000000000..c9cb6b085a --- /dev/null +++ b/packages/automation/src/lib/states/mint-pkp.spec.ts @@ -0,0 +1,78 @@ +import { LitContracts } from '@lit-protocol/contracts-sdk'; + +import { MintPKPState, MintPKPStateParams } from './mint-pkp'; + +describe('MintPKPState', () => { + let mockLitContracts: LitContracts; + let mockCallback: jest.Mock; + let mockMint: jest.Mock; + + beforeEach(() => { + mockMint = jest.fn().mockResolvedValue({ + pkp: { + tokenId: '123', + publicKey: '0xPublicKey', + ethAddress: '0xEthAddress', + }, + }); + + mockLitContracts = { + pkpNftContractUtils: { + write: { + mint: mockMint, + }, + }, + } as unknown as LitContracts; + + mockCallback = jest.fn(); + }); + + it('should mint a PKP and call the callback with PKP info', async () => { + const params: MintPKPStateParams = { + key: 'MintPKPState', + litContracts: mockLitContracts, + callback: mockCallback, + }; + + const state = new MintPKPState(params); + + await state.enter(); + + expect(mockMint).toHaveBeenCalled(); + expect(mockCallback).toHaveBeenCalledWith({ + tokenId: '123', + publicKey: '0xPublicKey', + ethAddress: '0xEthAddress', + }); + }); + + it('should handle errors during minting', async () => { + mockMint.mockRejectedValue(new Error('Minting error')); + + const params: MintPKPStateParams = { + key: 'MintPKPState', + litContracts: mockLitContracts, + callback: mockCallback, + }; + + const state = new MintPKPState(params); + + await expect(state.enter()).rejects.toThrow('Minting error'); + }); + + it('should execute onEnter callback if provided', async () => { + const onEnter = jest.fn(); + const params: MintPKPStateParams = { + key: 'MintPKPState', + litContracts: mockLitContracts, + callback: mockCallback, + onEnter, + }; + + const state = new MintPKPState(params); + + await state.enter(); + + expect(onEnter).toHaveBeenCalled(); + }); +}); diff --git a/packages/automation/src/lib/states/mint-pkp.ts b/packages/automation/src/lib/states/mint-pkp.ts new file mode 100644 index 0000000000..a22003f269 --- /dev/null +++ b/packages/automation/src/lib/states/mint-pkp.ts @@ -0,0 +1,32 @@ +import { LitContracts } from '@lit-protocol/contracts-sdk'; + +import { State, StateParams } from './state'; + +export interface PKPInfo { + tokenId: string; + publicKey: string; + ethAddress: string; +} + +export interface MintPKPStateParams extends StateParams { + litContracts: LitContracts; + callback: (pkpInfo: PKPInfo) => void; +} + +export class MintPKPState extends State { + constructor(params: MintPKPStateParams) { + const superParams: StateParams = { + key: params.key, + debug: params.debug, + onExit: params.onExit, + onEnter: async () => { + const mintingReceipt = + await params.litContracts.pkpNftContractUtils.write.mint(); + params.callback(mintingReceipt.pkp); + await params.onEnter?.(); + }, + }; + + super(superParams); + } +} diff --git a/packages/automation/src/lib/states/state.spec.ts b/packages/automation/src/lib/states/state.spec.ts new file mode 100644 index 0000000000..7f08ebe3da --- /dev/null +++ b/packages/automation/src/lib/states/state.spec.ts @@ -0,0 +1,54 @@ +import { State } from './state'; + +describe('State', () => { + it('should create state with name', () => { + const state = new State({ key: 'TestState' }); + expect(state.key).toBe('TestState'); + }); + + it('should execute onEnter callback when entering state', async () => { + const onEnter = jest.fn(); + const state = new State({ key: 'TestState', onEnter }); + + await state.enter(); + + expect(onEnter).toHaveBeenCalled(); + }); + + it('should execute onExit callback when exiting state', async () => { + const onExit = jest.fn(); + const state = new State({ key: 'TestState', onExit }); + + await state.exit(); + + expect(onExit).toHaveBeenCalled(); + }); + + it('should not throw when entering state without onEnter callback', async () => { + const state = new State({ key: 'TestState' }); + await expect(() => state.enter()).not.toThrow(); + }); + + it('should not throw when exiting state without onExit callback', async () => { + const state = new State({ key: 'TestState' }); + await expect(() => state.exit()).not.toThrow(); + }); + + it('should handle throwing onEnter callback', async () => { + const onEnter = jest.fn().mockImplementation(() => { + throw new Error('Enter error'); + }); + const state = new State({ key: 'TestState', onEnter }); + + await expect(() => state.enter()).rejects.toThrow('Enter error'); + }); + + it('should handle throwing onExit callback', async () => { + const onExit = jest.fn().mockImplementation(() => { + throw new Error('Exit error'); + }); + const state = new State({ key: 'TestState', onExit }); + + await expect(() => state.exit()).rejects.toThrow('Exit error'); + }); +}); diff --git a/packages/automation/src/lib/states/state.ts b/packages/automation/src/lib/states/state.ts new file mode 100644 index 0000000000..5b5b84018b --- /dev/null +++ b/packages/automation/src/lib/states/state.ts @@ -0,0 +1,41 @@ +export interface BaseStateParams { + key: string; + onEnter?: () => Promise; + onExit?: () => Promise; + debug?: boolean; +} + +export type StateParams = BaseStateParams; + +/** + * A State class that represents a state with optional entry and exit actions. + */ +export class State { + public readonly key: string; + public readonly onEnter: (() => Promise) | undefined; + public readonly onExit: (() => Promise) | undefined; + private debug = false; + + constructor(private params: BaseStateParams) { + this.key = params.key; + this.onEnter = params.onEnter; + this.onExit = params.onExit; + this.debug = params.debug ?? false; + } + + /** + * Executes the onEnter action for the state. + */ + async enter() { + this.debug && console.log(`enter ${this.key}`); + await this.onEnter?.(); + } + + /** + * Executes the onExit action for the state. + */ + async exit() { + this.debug && console.log(`exit ${this.key}`); + await this.onExit?.(); + } +} diff --git a/packages/automation/src/lib/transitions/index.ts b/packages/automation/src/lib/transitions/index.ts new file mode 100644 index 0000000000..df7a702a10 --- /dev/null +++ b/packages/automation/src/lib/transitions/index.ts @@ -0,0 +1 @@ +export * from './transition'; diff --git a/packages/automation/src/lib/transitions/transition.spec.ts b/packages/automation/src/lib/transitions/transition.spec.ts new file mode 100644 index 0000000000..62b295681c --- /dev/null +++ b/packages/automation/src/lib/transitions/transition.spec.ts @@ -0,0 +1,114 @@ +import { TimerListener } from '../listeners'; +import { Transition } from './transition'; + +function coalesce(value: number | undefined) { + return value ?? 0; +} + +describe('Transition', () => { + let transition: Transition; + let listener1: TimerListener; + let listener2: TimerListener; + let check: jest.Mock; + let onMatch: jest.Mock; + let onMismatch: jest.Mock; + + beforeEach(() => { + jest.useFakeTimers(); + check = jest.fn((values: (number | undefined)[]) => { + const [val1, val2] = values.map(coalesce); + return val1 >= 3 && val2 >= 2; + }); + onMatch = jest.fn(); + onMismatch = jest.fn(); + listener1 = new TimerListener(1000); + listener2 = new TimerListener(2000); + transition = new Transition({ + listeners: [listener1, listener2], + check, + onMatch, + onMismatch, + }); + }); + + it('should call onMatch when check is true', async () => { + await transition.startListening(); + + // After 4 seconds (listener1 counter = 4, listener2 counter = 2) + jest.advanceTimersByTime(4000); + await expect(check).toHaveBeenCalledTimes(6); + await expect(onMismatch).toHaveBeenCalledTimes(5); // 4 for listener1, 2 for listener2. But last one matched + await expect(onMatch).toHaveBeenCalledTimes(1); + await expect(onMatch).toHaveBeenCalledWith([4, 2]); // The last one is matched + }); + + it('should call onMismatch when check is false', async () => { + await transition.startListening(); + + // After 3 seconds (listener1 counter = 3, listener2 counter = 1) + jest.advanceTimersByTime(3000); + await expect(check).toHaveBeenCalledTimes(4); + await expect(onMismatch).toHaveBeenCalledTimes(4); // 3 for listener1, 1 for listener2 + await expect(onMismatch).toHaveBeenCalledWith([3, 1]); // Last of failing values + await expect(onMatch).not.toHaveBeenCalled(); + }); + + it('should stop calling callbacks after stopListening', async () => { + await transition.startListening(); + + // After 2 seconds + jest.advanceTimersByTime(3000); + await expect(check).toHaveBeenCalledTimes(4); + await expect(onMismatch).toHaveBeenCalledTimes(4); // 3 for listener1, 1 for listener2 + await expect(onMismatch).toHaveBeenCalledWith([3, 1]); // Example of checking values + + await transition.stopListening(); + + // After another 2 seconds + jest.advanceTimersByTime(2000); + await expect(check).toHaveBeenCalledTimes(4); // No additional calls + await expect(onMismatch).toHaveBeenCalledTimes(4); // No additional calls + await expect(onMatch).not.toHaveBeenCalled(); + }); + + it('should handle missing listeners, check and onMismatch callbacks gracefully', async () => { + const basicTransition = new Transition({ + onMatch, + }); + await basicTransition.startListening(); + + // Advance time without callbacks + jest.advanceTimersByTime(6000); + await expect(() => basicTransition.stopListening()).not.toThrow(); + }); + + it('should automatically call onMatch if check is not provided', async () => { + const autoMatchTransition = new Transition({ + listeners: [listener1, listener2], + onMatch, + }); + await autoMatchTransition.startListening(); + + // After 2 seconds (listener1 counter = 2, listener2 counter = 1) + jest.advanceTimersByTime(2000); + await expect(onMatch).toHaveBeenCalledTimes(3); // Called for each state change + await expect(onMatch).toHaveBeenCalledWith([2, 1]); + }); + + it('should automatically call onMatch if there are no listeners and no check function', async () => { + const noListenerTransition = new Transition({ + onMatch, + }); + await noListenerTransition.startListening(); + + // Since there are no listeners, onMatch should be called immediately + jest.runAllTimers(); + await expect(onMatch).toHaveBeenCalledTimes(1); + await expect(onMatch).toHaveBeenCalledWith([]); + }); + + afterEach(async () => { + await transition.stopListening(); + jest.useRealTimers(); + }); +}); diff --git a/packages/automation/src/lib/transitions/transition.ts b/packages/automation/src/lib/transitions/transition.ts new file mode 100644 index 0000000000..d764aa745a --- /dev/null +++ b/packages/automation/src/lib/transitions/transition.ts @@ -0,0 +1,82 @@ +import { Listener } from '../listeners'; + +/** + * A Transition class that manages state transitions based on listeners and conditions. + */ +export interface BaseTransitionParams { + listeners?: Listener[]; + check?: (values: (any | undefined)[]) => Promise; + onMatch: (values: (any | undefined)[]) => Promise; + onMismatch?: (values: (any | undefined)[]) => Promise; +} + +export class Transition { + private debug = false; + private listeners: Listener[]; + private readonly values: (any | undefined)[]; + private readonly check?: (values: (any | undefined)[]) => Promise; + private readonly onMatch: (values: (any | undefined)[]) => Promise; + private readonly onMismatch?: (values: (any | undefined)[]) => Promise; + + /** + * Creates a new Transition instance. If no listeners are provided, the transition will automatically match on the next event loop. + * + * @param params An object containing listeners, check function, and optional onMatch and onMismatch functions. + */ + constructor({ + listeners = [], + check, + onMatch, + onMismatch, + }: BaseTransitionParams) { + this.listeners = listeners; + this.check = check; + this.onMatch = onMatch; + this.onMismatch = onMismatch; + this.values = new Array(listeners.length).fill(undefined); + this.setupListeners(); + } + + /** + * Sets up listeners for state changes and handles transition logic. + */ + private setupListeners() { + this.listeners.forEach((listener, index) => { + listener.onStateChange(async (value: any) => { + this.values[index] = value; + const isMatch = this.check ? await this.check(this.values) : true; + if (isMatch) { + this.debug && console.log('match', this.values); + await this.onMatch?.(this.values); + } else { + this.debug && console.log('mismatch', this.values); + await this.onMismatch?.(this.values); + } + }); + }); + } + + /** + * Starts all listeners for this transition. + */ + async startListening() { + this.debug && console.log('startListening'); + await Promise.all(this.listeners.map((listener) => listener.start())); + + if (!this.listeners.length) { + // If the transition does not have any listeners it will never emit. Therefore, we "emit" automatically on next event loop + setTimeout(() => { + this.debug && console.log('Transition without listeners: auto match'); + this.onMatch([]); + }, 0); + } + } + + /** + * Stops all listeners for this transition. + */ + async stopListening() { + this.debug && console.log('stopListening'); + await Promise.all(this.listeners.map((listener) => listener.stop())); + } +} diff --git a/packages/automation/tsconfig.json b/packages/automation/tsconfig.json new file mode 100644 index 0000000000..d3187ebeee --- /dev/null +++ b/packages/automation/tsconfig.json @@ -0,0 +1,25 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "module": "system", + "forceConsistentCasingInFileNames": true, + "strict": true, + "noImplicitOverride": true, + "noPropertyAccessFromIndexSignature": true, + "noImplicitReturns": true, + "noFallthroughCasesInSwitch": true, + "allowJs": true, + "checkJs": false, + "resolveJsonModule": false + }, + "files": [], + "include": ["global.d.ts"], + "references": [ + { + "path": "./tsconfig.lib.json" + }, + { + "path": "./tsconfig.spec.json" + } + ] +} diff --git a/packages/automation/tsconfig.lib.json b/packages/automation/tsconfig.lib.json new file mode 100644 index 0000000000..8261486edc --- /dev/null +++ b/packages/automation/tsconfig.lib.json @@ -0,0 +1,12 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "outDir": "../../dist/out-tsc", + "declaration": true, + "types": [], + "allowJs": true, + "checkJs": false + }, + "include": ["**/*.ts"], + "exclude": ["jest.config.ts", "**/*.spec.ts", "**/*.test.ts"] +} diff --git a/packages/automation/tsconfig.spec.json b/packages/automation/tsconfig.spec.json new file mode 100644 index 0000000000..48d6d00bb4 --- /dev/null +++ b/packages/automation/tsconfig.spec.json @@ -0,0 +1,11 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "outDir": "../../dist/out-tsc", + "module": "commonjs", + "types": ["jest", "node"], + "allowJs": true, + "checkJs": false + }, + "include": ["jest.config.ts", "**/*.test.ts", "**/*.spec.ts", "**/*.d.ts"] +} diff --git a/packages/wrapped-keys-lit-actions/jest.config.ts b/packages/wrapped-keys-lit-actions/jest.config.ts index e36b3f094b..cfe699aa1b 100644 --- a/packages/wrapped-keys-lit-actions/jest.config.ts +++ b/packages/wrapped-keys-lit-actions/jest.config.ts @@ -11,6 +11,6 @@ export default { '^.+\\.[t]s$': 'ts-jest', }, moduleFileExtensions: ['ts', 'js', 'html'], - coverageDirectory: '../../coverage/packages/types', + coverageDirectory: '../../coverage/packages/wrapped-keys-lit-actions', setupFilesAfterEnv: ['../../jest.setup.js'], }; diff --git a/packages/wrapped-keys-lit-actions/tsconfig.lib.json b/packages/wrapped-keys-lit-actions/tsconfig.lib.json index c89e6dbca4..ce61706108 100644 --- a/packages/wrapped-keys-lit-actions/tsconfig.lib.json +++ b/packages/wrapped-keys-lit-actions/tsconfig.lib.json @@ -7,6 +7,6 @@ "allowJs": true, "checkJs": false }, - "include": ["**/*.ts", "esbuild.config.js", "esbuild.config.js"], + "include": ["**/*.ts", "esbuild.config.js"], "exclude": ["jest.config.ts", "**/*.spec.ts", "**/*.test.ts"] } diff --git a/packages/wrapped-keys/jest.config.ts b/packages/wrapped-keys/jest.config.ts index e36b3f094b..f775242d3f 100644 --- a/packages/wrapped-keys/jest.config.ts +++ b/packages/wrapped-keys/jest.config.ts @@ -11,6 +11,6 @@ export default { '^.+\\.[t]s$': 'ts-jest', }, moduleFileExtensions: ['ts', 'js', 'html'], - coverageDirectory: '../../coverage/packages/types', + coverageDirectory: '../../coverage/packages/wrapped-keys', setupFilesAfterEnv: ['../../jest.setup.js'], }; diff --git a/yarn.lock b/yarn.lock index 452aa24cc1..55660ae082 100644 --- a/yarn.lock +++ b/yarn.lock @@ -5495,6 +5495,11 @@ dependencies: "@types/node" "*" +"@types/events@^3.0.3": + version "3.0.3" + resolved "https://registry.yarnpkg.com/@types/events/-/events-3.0.3.tgz#a8ef894305af28d1fc6d2dfdfc98e899591ea529" + integrity sha512-trOc4AAUThEz9hapPtSd7wf5tiQKvTtu5b371UxXdTuqzIh0ArcRspRP0i0Viu+LXstIQ1z96t1nsPxT9ol01g== + "@types/graceful-fs@^4.1.2", "@types/graceful-fs@^4.1.3": version "4.1.9" resolved "https://registry.yarnpkg.com/@types/graceful-fs/-/graceful-fs-4.1.9.tgz#2a06bc0f68a20ab37b3e36aa238be6abdf49e8b4" @@ -6866,7 +6871,7 @@ argv-formatter@~1.0.0: resolved "https://registry.yarnpkg.com/argv-formatter/-/argv-formatter-1.0.0.tgz#a0ca0cbc29a5b73e836eebe1cbf6c5e0e4eb82f9" integrity sha512-F2+Hkm9xFaRg+GkaNnbwXNDV5O6pnCFEmqyhvfC/Ic5LbgOWjJh3L+mN/s91rxVL3znE7DYVpW0GJFT+4YBgWw== -aria-query@5.1.3: +aria-query@5.1.3, aria-query@~5.1.3: version "5.1.3" resolved "https://registry.yarnpkg.com/aria-query/-/aria-query-5.1.3.tgz#19db27cd101152773631396f7a95a3b58c22c35e" integrity sha512-R5iJ5lkuHybztUfuOAznmboyjWq8O6sqNqtK7CLOqdydi54VNbORp49mb14KbWgG1QD3JFO9hJdZ+y4KutfdOQ== @@ -7176,7 +7181,7 @@ aws4@^1.8.0: resolved "https://registry.yarnpkg.com/aws4/-/aws4-1.13.2.tgz#0aa167216965ac9474ccfa83892cfb6b3e1e52ef" integrity sha512-lHe62zvbTB5eEABUVi/AwVh0ZKY9rMMDhmm+eeyuuUQbQ3+J+fONVQOZyj+DdrvD4BY33uYniyRJ4UJIaSKAfw== -axe-core@^4.10.0: +axe-core@^4.10.0, axe-core@^4.9.1: version "4.10.2" resolved "https://registry.yarnpkg.com/axe-core/-/axe-core-4.10.2.tgz#85228e3e1d8b8532a27659b332e39b7fa0e022df" integrity sha512-RE3mdQ7P3FRSe7eqCWoeQ/Z9QXrtniSjp1wUjt5nRC3WIpz5rSCve6o3fsZ2aCpJtrZjSZgjwXAoTO5k4tEI0w== @@ -7218,6 +7223,13 @@ axobject-query@^4.1.0: resolved "https://registry.yarnpkg.com/axobject-query/-/axobject-query-4.1.0.tgz#28768c76d0e3cff21bc62a9e2d0b6ac30042a1ee" integrity sha512-qIj0G9wZbMGNLjLmg1PT6v2mE9AH2zlnADJD/2tC6E00hgmhUOfEB6greHPAfLRSufHqROIUTkw6E+M3lH0PTQ== +axobject-query@~3.1.1: + version "3.1.1" + resolved "https://registry.yarnpkg.com/axobject-query/-/axobject-query-3.1.1.tgz#3b6e5c6d4e43ca7ba51c5babf99d22a9c68485e1" + integrity sha512-goKlv8DZrK9hUh975fnHzhNIO4jUnFCfv/dszV5VwUGDFjI6vQ2VwoyjYjYNEbBE8AH87TduWP5uyDR1D+Iteg== + dependencies: + deep-equal "^2.0.5" + babel-code-frame@^6.26.0: version "6.26.0" resolved "https://registry.yarnpkg.com/babel-code-frame/-/babel-code-frame-6.26.0.tgz#63fd43f7dc1e3bb7ce35947db8fe369a3f58c74b" @@ -11085,7 +11097,7 @@ es-get-iterator@^1.1.3: isarray "^2.0.5" stop-iteration-iterator "^1.0.0" -es-iterator-helpers@^1.1.0: +es-iterator-helpers@^1.0.19, es-iterator-helpers@^1.1.0: version "1.2.0" resolved "https://registry.yarnpkg.com/es-iterator-helpers/-/es-iterator-helpers-1.2.0.tgz#2f1a3ab998b30cb2d10b195b587c6d9ebdebf152" integrity sha512-tpxqxncxnpw3c93u8n3VOzACmRFoVmWJqbWXvX/JfKbkhBw1oslgPrUfeSt2psuqyEJFD6N/9lg5i7bsKpoq+Q== @@ -21942,7 +21954,7 @@ string-length@^4.0.1: char-regex "^1.0.2" strip-ansi "^6.0.0" -"string-width-cjs@npm:string-width@^4.2.0", "string-width@^1.0.2 || 2 || 3 || 4", string-width@^4.0.0, string-width@^4.1.0, string-width@^4.2.0, string-width@^4.2.3: +"string-width-cjs@npm:string-width@^4.2.0": version "4.2.3" resolved "https://registry.yarnpkg.com/string-width/-/string-width-4.2.3.tgz#269c7117d27b05ad2e536830a8ec895ef9c6d010" integrity sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g== @@ -21960,6 +21972,15 @@ string-width@^1.0.1: is-fullwidth-code-point "^1.0.0" strip-ansi "^3.0.0" +"string-width@^1.0.2 || 2 || 3 || 4", string-width@^4.0.0, string-width@^4.1.0, string-width@^4.2.0, string-width@^4.2.3: + version "4.2.3" + resolved "https://registry.yarnpkg.com/string-width/-/string-width-4.2.3.tgz#269c7117d27b05ad2e536830a8ec895ef9c6d010" + integrity sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g== + dependencies: + emoji-regex "^8.0.0" + is-fullwidth-code-point "^3.0.0" + strip-ansi "^6.0.1" + string-width@^2.0.0, string-width@^2.1.0: version "2.1.1" resolved "https://registry.yarnpkg.com/string-width/-/string-width-2.1.1.tgz#ab93f27a8dc13d28cac815c462143a6d9012ae9e" @@ -21986,7 +22007,7 @@ string-width@^5.0.1, string-width@^5.1.2: emoji-regex "^9.2.2" strip-ansi "^7.0.1" -string.prototype.includes@^2.0.1: +string.prototype.includes@^2.0.0, string.prototype.includes@^2.0.1: version "2.0.1" resolved "https://registry.yarnpkg.com/string.prototype.includes/-/string.prototype.includes-2.0.1.tgz#eceef21283640761a81dbe16d6c7171a4edf7d92" integrity sha512-o7+c9bW6zpAdJHTtujeePODAhkuicdAryFsfVKwA+wGw89wJ4GTY484WTucM9hLtDEOpOvI+aHnzqnC5lHp4Rg== @@ -22081,7 +22102,7 @@ stringify-package@^1.0.1: resolved "https://registry.yarnpkg.com/stringify-package/-/stringify-package-1.0.1.tgz#e5aa3643e7f74d0f28628b72f3dad5cecfc3ba85" integrity sha512-sa4DUQsYciMP1xhKWGuFM04fB0LG/9DlluZoSVywUMRNvzid6XucHK0/90xGxRoHrAaROrcHK1aPKaijCtSrhg== -"strip-ansi-cjs@npm:strip-ansi@^6.0.1", strip-ansi@^6.0.0, strip-ansi@^6.0.1: +"strip-ansi-cjs@npm:strip-ansi@^6.0.1": version "6.0.1" resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-6.0.1.tgz#9e26c63d30f53443e9489495b2105d37b67a85d9" integrity sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A== @@ -22109,6 +22130,13 @@ strip-ansi@^5.0.0, strip-ansi@^5.1.0, strip-ansi@^5.2.0: dependencies: ansi-regex "^4.1.0" +strip-ansi@^6.0.0, strip-ansi@^6.0.1: + version "6.0.1" + resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-6.0.1.tgz#9e26c63d30f53443e9489495b2105d37b67a85d9" + integrity sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A== + dependencies: + ansi-regex "^5.0.1" + strip-ansi@^7.0.1: version "7.1.0" resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-7.1.0.tgz#d5b6568ca689d8561370b0707685d22434faff45" @@ -24251,7 +24279,7 @@ wordwrap@^1.0.0: resolved "https://registry.yarnpkg.com/wordwrap/-/wordwrap-1.0.0.tgz#27584810891456a4171c8d0226441ade90cbcaeb" integrity sha512-gvVzJFlPycKc5dZN4yPkP8w7Dc37BtP1yczEneOb4uq34pXZcvrtRTmWV8W+Ume+XCxKgbjM+nevkyFPMybd4Q== -"wrap-ansi-cjs@npm:wrap-ansi@^7.0.0", wrap-ansi@^7.0.0: +"wrap-ansi-cjs@npm:wrap-ansi@^7.0.0": version "7.0.0" resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-7.0.0.tgz#67e145cff510a6a6984bdf1152911d69d2eb9e43" integrity sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q== @@ -24286,6 +24314,15 @@ wrap-ansi@^6.0.1, wrap-ansi@^6.2.0: string-width "^4.1.0" strip-ansi "^6.0.0" +wrap-ansi@^7.0.0: + version "7.0.0" + resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-7.0.0.tgz#67e145cff510a6a6984bdf1152911d69d2eb9e43" + integrity sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q== + dependencies: + ansi-styles "^4.0.0" + string-width "^4.1.0" + strip-ansi "^6.0.0" + wrap-ansi@^8.0.1, wrap-ansi@^8.1.0: version "8.1.0" resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-8.1.0.tgz#56dc22368ee570face1b49819975d9b9a5ead214"