diff --git a/package.json b/package.json index e3bf0a89..68b6d66b 100644 --- a/package.json +++ b/package.json @@ -4,7 +4,7 @@ "private": true, "dependencies": { "@pybricks/firmware": "^1.0.0", - "@pybricks/mpy-cross-v4": "^1.0.0", + "@pybricks/mpy-cross-v4": "^1.1.1", "@shopify/react-i18n": "^3.0.2", "@testing-library/jest-dom": "^5.8.0", "@testing-library/react": "^10.0.4", @@ -36,7 +36,6 @@ "redux-logger": "^3.0.6", "redux-observable": "^1.2.0", "redux-saga": "^1.1.3", - "redux-thunk": "^2.3.0", "typescript": "~3.9.3", "xterm": "^4.6.0", "xterm-addon-fit": "^0.4.0" diff --git a/src/actions/index.ts b/src/actions/index.ts index 3edb50f4..89358d56 100644 --- a/src/actions/index.ts +++ b/src/actions/index.ts @@ -9,6 +9,7 @@ import { } from './bootloader'; import { EditorAction } from './editor'; import { HubAction, HubMessageAction } from './hub'; +import { MpyAction } from './mpy'; import { NotificationAction } from './notification'; import { ServiceWorkerAction } from './service-worker'; import { TerminalDataAction } from './terminal'; @@ -28,6 +29,7 @@ export type Action = | EditorAction | HubMessageAction | HubAction + | MpyAction | NotificationAction | ServiceWorkerAction | TerminalDataAction; diff --git a/src/actions/mpy.ts b/src/actions/mpy.ts index e6be0b31..3c4e87a9 100644 --- a/src/actions/mpy.ts +++ b/src/actions/mpy.ts @@ -1,30 +1,47 @@ // SPDX-License-Identifier: MIT // Copyright (c) 2020 The Pybricks Authors -import { compile as mpyCrossCompile } from '@pybricks/mpy-cross-v4'; import { Action } from 'redux'; -import { ThunkAction } from 'redux-thunk'; export enum MpyActionType { - Compiled = 'mpy.action.compile', + Compile = 'mpy.action.compile', + DidCompile = 'mpy.action.didCompile', + DidFailToCompile = 'mpy.action.didFailToCompile', } -export interface MpyCompiledAction extends Action { - /** - * The compiled .mpy data. - */ - data?: Uint8Array; - /** - * Error output. - */ - err?: string; +/** Action that requests that a script is compiled. */ +export interface MpyCompileAction extends Action { + /** The script to compile. */ + readonly script: string; + /** The compiler command line options */ + options?: string[]; } -type MpyCompileAction = ThunkAction, {}, {}, Action>; - export function compile(script: string, options?: string[]): MpyCompileAction { - return async function (): Promise { - const result = await mpyCrossCompile('main.py', script, options); - return { type: MpyActionType.Compiled, data: result.mpy, err: result.err }; - }; + return { type: MpyActionType.Compile, script, options }; } + +export interface MpyDidCompileAction extends Action { + /** The compiled .mpy file. */ + readonly data: Uint8Array; +} + +export function didCompile(data: Uint8Array): MpyDidCompileAction { + return { type: MpyActionType.DidCompile, data }; +} + +export interface MpyDidFailToCompileAction + extends Action { + /** Error output. */ + readonly err: string; +} + +export function didFailToCompile(err: string): MpyDidFailToCompileAction { + return { type: MpyActionType.DidFailToCompile, err }; +} + +/** Common type for all mpy actions. */ +export type MpyAction = + | MpyCompileAction + | MpyDidCompileAction + | MpyDidFailToCompileAction; diff --git a/src/index.tsx b/src/index.tsx index 4f3bbe9c..0b3e8fdf 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -9,7 +9,6 @@ import { applyMiddleware, createStore } from 'redux'; import { createLogger } from 'redux-logger'; import { createEpicMiddleware } from 'redux-observable'; import createSagaMiddleware from 'redux-saga'; -import thunkMiddleware from 'redux-thunk'; import './index.scss'; import { success, update } from './actions/service-worker'; import App from './components/App'; @@ -33,7 +32,6 @@ const i18n = new I18nManager({ const store = createStore( rootReducer, applyMiddleware( - thunkMiddleware, sagaMiddleware, epicMiddleware, serviceMiddleware, diff --git a/src/sagas/bootloader.ts b/src/sagas/bootloader.ts index b290720d..93854265 100644 --- a/src/sagas/bootloader.ts +++ b/src/sagas/bootloader.ts @@ -12,7 +12,6 @@ import { delay, fork, put, - putResolve, race, take, takeEvery, @@ -59,7 +58,12 @@ import { send, stateResponse, } from '../actions/bootloader'; -import { MpyCompiledAction, compile } from '../actions/mpy'; +import { + MpyActionType, + MpyDidCompileAction, + MpyDidFailToCompileAction, + compile, +} from '../actions/mpy'; import * as notification from '../actions/notification'; import { Command, @@ -262,12 +266,14 @@ function* loadFirmware( ); } - const mpy = (yield putResolve( - (compile(main, metadata['mpy-cross-options']) as unknown) as Action, - )) as MpyCompiledAction; + yield put(compile(main, metadata['mpy-cross-options'])); + const [mpy, mpyFail] = (yield race([ + take(MpyActionType.DidCompile), + take(MpyActionType.DidFailToCompile), + ])) as [MpyDidCompileAction, MpyDidFailToCompileAction]; - if (!mpy.data) { - throw Error(mpy.err); + if (mpyFail) { + throw Error(mpyFail.err); } // compute offset for checksum - must be aligned to 4-byte boundary diff --git a/src/sagas/index.ts b/src/sagas/index.ts index e8cd0942..6b188e5c 100644 --- a/src/sagas/index.ts +++ b/src/sagas/index.ts @@ -3,8 +3,9 @@ import { all } from 'redux-saga/effects'; import bootloader from './bootloader'; +import mpy from './mpy'; /* istanbul ignore next */ export default function* (): Generator { - yield all([bootloader()]); + yield all([bootloader(), mpy()]); } diff --git a/src/sagas/mpy.test.ts b/src/sagas/mpy.test.ts new file mode 100644 index 00000000..d45f4b85 --- /dev/null +++ b/src/sagas/mpy.test.ts @@ -0,0 +1,67 @@ +import { runSaga, stdChannel } from 'redux-saga'; +import { Action } from '../actions'; +import { + MpyActionType, + MpyDidCompileAction, + MpyDidFailToCompileAction, + compile, +} from '../actions/mpy'; +import mpy from './mpy'; + +enum MpyFeatureFlags { + MICROPY_OPT_CACHE_MAP_LOOKUP_IN_BYTECODE = 1 << 0, + MICROPY_PY_BUILTINS_STR_UNICODE = 1 << 1, +} + +test('compiler works', async () => { + const channel = stdChannel(); + const dispatched = new Array(); + const task = runSaga( + { + channel, + dispatch: (action: Action) => dispatched.push(action), + }, + mpy, + ); + channel.put(compile('print("hello!")')); + + // TODO: not sure what the best way to handle this is. We could just wait + // for one dispatch, but then we could miss a bug where there is more than + // one dispatch. And if we make the time too short, we could get intermittent + // failures. + setTimeout(() => task.cancel(), 1000); + await task.toPromise(); + + expect(dispatched.length).toBe(1); + expect(dispatched[0].type).toBe(MpyActionType.DidCompile); + const { data } = dispatched[0] as MpyDidCompileAction; + expect(data[0]).toBe('M'.charCodeAt(0)); + expect(data[1]).toBe(4); // ABI version + expect(data[2]).toBe(MpyFeatureFlags.MICROPY_PY_BUILTINS_STR_UNICODE); + expect(data[3]).toBe(31); // small int bits +}); + +test('compiler error works', async () => { + const channel = stdChannel(); + const dispatched = new Array(); + const task = runSaga( + { + channel, + dispatch: (action: Action) => dispatched.push(action), + }, + mpy, + ); + channel.put(compile('syntax error!')); + + // TODO: not sure what the best way to handle this is. We could just wait + // for one dispatch, but then we could miss a bug where there is more than + // one dispatch. And if we make the time too short, we could get intermittent + // failures. + setTimeout(() => task.cancel(), 1000); + await task.toPromise(); + + expect(dispatched.length).toBe(1); + expect(dispatched[0].type).toBe(MpyActionType.DidFailToCompile); + const { err } = dispatched[0] as MpyDidFailToCompileAction; + expect(err).toContain('SyntaxError'); +}); diff --git a/src/sagas/mpy.ts b/src/sagas/mpy.ts new file mode 100644 index 00000000..73cb0de7 --- /dev/null +++ b/src/sagas/mpy.ts @@ -0,0 +1,31 @@ +// SPDX-License-Identifier: MIT +// Copyright (c) 2020 The Pybricks Authors + +import { CompileResult, compile as mpyCrossCompile } from '@pybricks/mpy-cross-v4'; +import { call, put, takeEvery } from 'redux-saga/effects'; +import { + MpyActionType, + MpyCompileAction, + didCompile, + didFailToCompile, +} from '../actions/mpy'; + +/** + * Compiles a script to .mpy and dispatches either didCompile on success or + * didFailToCompile on error. + * @param action A mpy compile action. + */ +function* compile(action: MpyCompileAction): Generator { + const result = (yield call(() => + mpyCrossCompile('main.py', action.script, action.options), + )) as CompileResult; + if (result.status === 0 && result.mpy) { + yield put(didCompile(result.mpy)); + } else { + yield put(didFailToCompile(result.err)); + } +} + +export default function* (): Generator { + yield takeEvery(MpyActionType.Compile, compile); +} diff --git a/src/services/hub.ts b/src/services/hub.ts index 6835a39f..d223d33a 100644 --- a/src/services/hub.ts +++ b/src/services/hub.ts @@ -1,22 +1,35 @@ // SPDX-License-Identifier: MIT // Copyright (c) 2020 The Pybricks Authors -import { AnyAction } from 'redux'; -import { ThunkDispatch } from 'redux-thunk'; -import { Action } from '../actions'; +import { EventEmitter } from 'events'; +import { Action, Dispatch } from '../actions'; import { write } from '../actions/ble'; import { HubActionType, HubRuntimeStatusType, updateStatus } from '../actions/hub'; -import { compile } from '../actions/mpy'; +import { + MpyActionType, + MpyDidCompileAction, + MpyDidFailToCompileAction, + compile, +} from '../actions/mpy'; import { getChecksum } from '../epics/hub'; import { RootState } from '../reducers'; import { combineServices } from '.'; // TODO: this file needs to be converted to a saga -type Dispatch = ThunkDispatch<{}, {}, AnyAction>; - const downloadChunkSize = 100; +const compiler = new EventEmitter(); + +function didCompile(action: Action): void { + if (action.type === MpyActionType.DidCompile) { + compiler.emit('didCompile', action); + } + if (action.type === MpyActionType.DidFailToCompile) { + compiler.emit('didFailToCompile', action); + } +} + async function downloadAndRun( action: Action, dispatch: Dispatch, @@ -32,11 +45,14 @@ async function downloadAndRun( console.log('no current editor'); return; } - const mpy = await dispatch(compile(script, ['-mno-unicode'])); - if (mpy.data === undefined) { - console.log(`failed to compile: ${mpy.err}`); - return; - } + + dispatch(compile(script, ['-mno-unicode'])); + const mpy = await new Promise((resolve, reject): void => { + compiler.on('didCompile', (a: MpyDidCompileAction): void => resolve(a)); + compiler.on('didFailToCompile', (a: MpyDidFailToCompileAction) => + reject(new Error(a.err)), + ); + }); // let everyone know the runtime is busy loading the program dispatch(updateStatus(HubRuntimeStatusType.Loading)); @@ -87,4 +103,4 @@ function stop(action: Action, dispatch: Dispatch): void { dispatch(write(stopCommand)); } -export default combineServices(downloadAndRun, startRepl, stop); +export default combineServices(didCompile, downloadAndRun, startRepl, stop); diff --git a/yarn.lock b/yarn.lock index dcd22cf1..727813d3 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1307,10 +1307,10 @@ resolved "https://npm.pkg.github.com/download/@pybricks/firmware/1.0.0/dfc52164caa622a8c5e0c59d110319441b7966f25e4d2764d592ce7b56672465#3f763dff5751d8e4b8ff94f73678ee30ac52233b" integrity sha512-eMGuzLRKVMvnZc093/7aW5/fGtRfz39aSLy+Drhc6j4tJqySBdRhNk709mH7ZYMj4RJ2D6mWNbuzi3LVGsVkAA== -"@pybricks/mpy-cross-v4@^1.0.0": - version "1.0.0" - resolved "https://npm.pkg.github.com/download/@pybricks/mpy-cross-v4/1.0.0/1961a986e77b302cb05c3e6dc03feefe06292488a05d23ea32d27e2f625e782a#82633377519ba904dbd3e0f700a4b38f8212a80f" - integrity sha512-2fWyh+ja+N6jje1o1WUOiCyciWERuXGZkLmYKdanluqtQuJcteCE6mzndgs5rxH4DuknKKmxjHuYwwzdDO0yDw== +"@pybricks/mpy-cross-v4@^1.1.1": + version "1.1.1" + resolved "https://npm.pkg.github.com/download/@pybricks/mpy-cross-v4/1.1.1/2000026e184543fbc83c5dca7dcc0af7b0703feb38a33f253803860544168a88#54b9a17c8d665e44c2e844ce4b257f038987a742" + integrity sha512-41fsUlyBv8vttoWaTBP8GjnBxkGPCuXi6mBIo8rPVUTIQmkmn8O5fGPPtYJjA3qKu6zboAFmI2kB+PBmbxoBkw== "@redux-saga/core@^1.1.3": version "1.1.3" @@ -9713,11 +9713,6 @@ redux-saga@^1.1.3: dependencies: "@redux-saga/core" "^1.1.3" -redux-thunk@^2.3.0: - version "2.3.0" - resolved "https://registry.yarnpkg.com/redux-thunk/-/redux-thunk-2.3.0.tgz#51c2c19a185ed5187aaa9a2d08b666d0d6467622" - integrity sha512-km6dclyFnmcvxhAcrQV2AkZmPQjzPDjgVlQtR0EQjxZPyJ0BnMf3in1ryuR8A2qU0HldVRfxYXbFSKlI3N7Slw== - redux@^3.6.0: version "3.7.2" resolved "https://registry.yarnpkg.com/redux/-/redux-3.7.2.tgz#06b73123215901d25d065be342eb026bc1c8537b"