diff --git a/src/actions/flash-firmware.ts b/src/actions/flash-firmware.ts index 0187bd6c..cda81bac 100644 --- a/src/actions/flash-firmware.ts +++ b/src/actions/flash-firmware.ts @@ -11,10 +11,8 @@ import { assert } from '../utils'; export enum FlashFirmwareActionType { /** Request to flash new firmware to the device. */ FlashFirmware = 'flashFirmware.action.flashFirmware', - /** Flashing started. */ + /** Actual modification of the flash memory on the device started. */ DidStart = 'flashFirmware.action.didStart', - /** Flashing was not able to start. */ - DidFailToStart = 'flashFirmware.action.didFailStart', /** Firmware flash progress. */ DidProgress = 'flashFirmware.action.didProgress', /** Flashing finished successfully. */ @@ -37,84 +35,49 @@ export enum HubError { } function isHubError(arg: unknown): arg is HubError { - if (typeof arg !== 'string') { - return false; - } - return Object.keys(HubError).includes(arg); + return Object.values(HubError).includes(arg as HubError); } -type Reason = { - reason: T; -}; - -export enum FailToStartReasonType { - /** Connecting to the hub failed. */ - FailedToConnect = 'flashFirmware.failToStart.reason.failedToConnect', - /** The is no firmware available that matches the connected hub. */ - NoFirmware = 'flashFirmware.failToStart.reason.noFirmware', - /** The provided firmware.zip does not match the connected hub. */ - DeviceMismatch = 'flashFirmware.failToStart.reason.deviceMismatch', - /** There was a problem with the zip file. */ - ZipError = 'flashFirmware.failToStart.reason.zipError', - /** Metadata property is missing or invalid. */ - BadMetadata = 'flashFirmware.failToStart.reason.badMetadata', - /** The main.py file failed to compile. */ - FailedToCompile = 'flashFirmware.failToStart.reason.failedToCompile', - /** The combined firmware-base.bin and main.mpy are too big. */ - FirmwareSize = 'flashFirmware.failToStart.reason.firmwareSize', - /** An unexpected error occurred. */ - Unknown = 'flashFirmware.failToStart.reason.unknown', -} - -export type FailToStartReasonFailedToConnect = Reason; - -export type FailToStartReasonNoFirmware = Reason; - -export type FailToStartReasonDeviceMismatch = Reason; - -export type FailToStartReasonZipError = Reason & { - err: FirmwareReaderError; -}; - -export type FailToStartReasonBadMetadata = Reason & { - property: keyof FirmwareMetadata; - problem: MetadataProblem; -}; - -export type FailToStartReasonFirmwareSize = Reason; - -export type FailToStartReasonFailedToCompile = Reason; - -export type FailToStartReasonUnknown = Reason & { - err: Error; -}; - -export type FailToStartReason = - | FailToStartReasonFailedToConnect - | FailToStartReasonNoFirmware - | FailToStartReasonDeviceMismatch - | FailToStartReasonZipError - | FailToStartReasonBadMetadata - | FailToStartReasonFirmwareSize - | FailToStartReasonFailedToCompile - | FailToStartReasonUnknown; - export enum FailToFinishReasonType { - /** Waiting for a response from the hub took too long. */ + /** Connecting to the hub failed. */ + FailedToConnect = 'flashFirmware.failToFinish.reason.failedToConnect', + /** The hub connection timed out. */ TimedOut = 'flashFirmware.failToFinish.reason.timedOut', /** Something went wrong with the BLE connection. */ BleError = 'flashFirmware.failToFinish.reason.bleError', - /** The BLE connection was lost before flashing completed. */ + /** The hub was disconnected. */ Disconnected = 'flashFirmware.failToFinish.reason.disconnected', /** The hub sent a response indicating a problem. */ HubError = 'flashFirmware.failToFinish.reason.hubError', + /** The is no firmware available that matches the connected hub. */ + NoFirmware = 'flashFirmware.failToFinish.reason.noFirmware', + /** The provided firmware.zip does not match the connected hub. */ + DeviceMismatch = 'flashFirmware.failToFinish.reason.deviceMismatch', + /** Failed to fetch firmware from the server. */ + FailedToFetch = 'flashFirmware.failToFinish.reason.failedToFetch', + /** There was a problem with the zip file. */ + ZipError = 'flashFirmware.failToFinish.reason.zipError', + /** Metadata property is missing or invalid. */ + BadMetadata = 'flashFirmware.failToFinish.reason.badMetadata', + /** The main.py file failed to compile. */ + FailedToCompile = 'flashFirmware.failToFinish.reason.failedToCompile', + /** The combined firmware-base.bin and main.mpy are too big. */ + FirmwareSize = 'flashFirmware.failToFinish.reason.firmwareSize', /** An unexpected error occurred. */ Unknown = 'flashFirmware.failToFinish.reason.unknown', } +type Reason = { + reason: T; +}; + +export type FailToFinishReasonFailedToConnect = Reason; + export type FailToFinishReasonTimedOut = Reason; -export type FailToFinishReasonBleError = Reason; +export type FailToFinishReasonBleError = Reason & { + err: Error; +}; export type FailToFinishReasonDisconnected = Reason; @@ -122,15 +85,44 @@ export type FailToFinishReasonHubError = Reason hubError: HubError; }; +export type FailToFinishReasonNoFirmware = Reason; + +export type FailToFinishReasonDeviceMismatch = Reason; + +export type FailToFinishReasonFailedToFetch = Reason & { + response: Response; +}; + +export type FailToFinishReasonZipError = Reason & { + err: FirmwareReaderError; +}; + +export type FailToFinishReasonBadMetadata = Reason & { + property: keyof FirmwareMetadata; + problem: MetadataProblem; +}; + +export type FailToFinishReasonFirmwareSize = Reason; + +export type FailToFinishReasonFailedToCompile = Reason; + export type FailToFinishReasonUnknown = Reason & { err: Error; }; export type FailToFinishReason = + | FailToFinishReasonFailedToConnect | FailToFinishReasonTimedOut | FailToFinishReasonBleError | FailToFinishReasonDisconnected | FailToFinishReasonHubError + | FailToFinishReasonNoFirmware + | FailToFinishReasonDeviceMismatch + | FailToFinishReasonFailedToFetch + | FailToFinishReasonZipError + | FailToFinishReasonBadMetadata + | FailToFinishReasonFirmwareSize + | FailToFinishReasonFailedToCompile | FailToFinishReasonUnknown; /** @@ -160,94 +152,6 @@ export function didStart(): FlashFirmwareDidStartAction { return { type: FlashFirmwareActionType.DidStart }; } -/** Action that indicates flashing did not start because of an error. */ -export type FlashFirmwareDidFailToStartAction = Action & { - reason: FailToStartReason; -}; - -export function didFailToStart( - reason: FailToStartReasonType.ZipError, - err: FirmwareReaderError, -): FlashFirmwareDidFailToStartAction; - -export function didFailToStart( - reason: FailToStartReasonType.BadMetadata, - property: keyof FirmwareMetadata, - problem: MetadataProblem, -): FlashFirmwareDidFailToStartAction; - -export function didFailToStart( - reason: FailToStartReasonType.Unknown, - err: Error, -): FlashFirmwareDidFailToStartAction; - -export function didFailToStart( - reason: Exclude< - FailToStartReasonType, - | FailToStartReasonType.ZipError - | FailToStartReasonType.BadMetadata - | FailToStartReasonType.Unknown - >, -): FlashFirmwareDidFailToStartAction; - -/** - * Action that indicates flashing did not start because of an error. - * @param total The total number of bytes to be flashed. - */ -export function didFailToStart( - reason: FailToStartReasonType, - arg1?: string | Error, - arg2?: MetadataProblem, -): FlashFirmwareDidFailToStartAction { - if (reason === FailToStartReasonType.ZipError) { - // istanbul ignore if: programmer error give wrong arg - if (!(arg1 instanceof FirmwareReaderError)) { - throw new Error('missing or invalid err'); - } - return { - type: FlashFirmwareActionType.DidFailToStart, - reason: { reason, err: arg1 }, - }; - } - - if (reason === FailToStartReasonType.BadMetadata) { - // istanbul ignore if: programmer error give wrong arg - if ( - arg1 !== 'metadata-version' && - arg1 !== 'firmware-version' && - arg1 !== 'device-id' && - arg1 !== 'checksum-type' && - arg1 !== 'mpy-abi-version' && - arg1 !== 'mpy-cross-options' && - arg1 !== 'user-mpy-offset' && - arg1 !== 'max-firmware-size' - ) { - throw new Error('missing or invalid property'); - } - // istanbul ignore if: programmer error give wrong arg - if (arg2 === undefined) { - throw new Error('missing or invalid problem'); - } - return { - type: FlashFirmwareActionType.DidFailToStart, - reason: { reason, property: arg1, problem: arg2 }, - }; - } - - if (reason === FailToStartReasonType.Unknown) { - // istanbul ignore if: programmer error give wrong arg - if (!(arg1 instanceof Error)) { - throw new Error('missing or invalid err'); - } - return { - type: FlashFirmwareActionType.DidFailToStart, - reason: { reason, err: arg1 }, - }; - } - - return { type: FlashFirmwareActionType.DidFailToStart, reason: { reason } }; -} - /** Action that indicates current firmware flashing progress. */ export type FlashFirmwareDidProgressAction = Action & { /** The current progress (0 to 1). */ @@ -276,11 +180,32 @@ export type FlashFirmwareDidFailToFinishAction = Action, ): FlashFirmwareDidFailToFinishAction; -/** Action that indicates that flashing failed. */ +/** + * Action that indicates flashing did not start because of an error. + * @param total The total number of bytes to be flashed. + */ export function didFailToFinish( reason: FailToFinishReasonType, - arg1?: HubError | Error, + arg1?: string | HubError | Error | Response, + arg2?: MetadataProblem, ): FlashFirmwareDidFailToFinishAction { - if (reason === FailToFinishReasonType.HubError) { + if (reason === FailToFinishReasonType.BleError) { // istanbul ignore if: programmer error give wrong arg - if (!isHubError(arg1)) { + if (!(arg1 instanceof Error)) { throw new Error('missing or invalid err'); } + return { + type: FlashFirmwareActionType.DidFailToFinish, + reason: { reason, err: arg1 }, + }; + } + + if (reason === FailToFinishReasonType.HubError) { + // istanbul ignore if: programmer error give wrong arg + if (!isHubError(arg1)) { + throw new Error('missing or invalid hubError'); + } return { type: FlashFirmwareActionType.DidFailToFinish, reason: { reason, hubError: arg1 }, }; } + if (reason === FailToFinishReasonType.FailedToFetch) { + // istanbul ignore if: programmer error give wrong arg + if (!(arg1 instanceof Response)) { + throw new Error('missing or invalid response'); + } + return { + type: FlashFirmwareActionType.DidFailToFinish, + reason: { reason, response: arg1 }, + }; + } + + if (reason === FailToFinishReasonType.ZipError) { + // istanbul ignore if: programmer error give wrong arg + if (!(arg1 instanceof FirmwareReaderError)) { + throw new Error('missing or invalid err'); + } + return { + type: FlashFirmwareActionType.DidFailToFinish, + reason: { reason, err: arg1 }, + }; + } + + if (reason === FailToFinishReasonType.BadMetadata) { + // istanbul ignore if: programmer error give wrong arg + if ( + arg1 !== 'metadata-version' && + arg1 !== 'firmware-version' && + arg1 !== 'device-id' && + arg1 !== 'checksum-type' && + arg1 !== 'mpy-abi-version' && + arg1 !== 'mpy-cross-options' && + arg1 !== 'user-mpy-offset' && + arg1 !== 'max-firmware-size' + ) { + throw new Error('missing or invalid property'); + } + // istanbul ignore if: programmer error give wrong arg + if (arg2 === undefined) { + throw new Error('missing or invalid problem'); + } + return { + type: FlashFirmwareActionType.DidFailToFinish, + reason: { reason, property: arg1, problem: arg2 }, + }; + } + if (reason === FailToFinishReasonType.Unknown) { // istanbul ignore if: programmer error give wrong arg if (!(arg1 instanceof Error)) { @@ -329,7 +320,6 @@ export function didFailToFinish( export type FlashFirmwareAction = | FlashFirmwareFlashAction | FlashFirmwareDidStartAction - | FlashFirmwareDidFailToStartAction | FlashFirmwareDidProgressAction | FlashFirmwareDidFinishAction | FlashFirmwareDidFailToFinishAction; diff --git a/src/actions/lwp3-bootloader.ts b/src/actions/lwp3-bootloader.ts index 6bcfbe6a..c0066c1b 100644 --- a/src/actions/lwp3-bootloader.ts +++ b/src/actions/lwp3-bootloader.ts @@ -41,6 +41,10 @@ export enum BootloaderConnectionActionType { * The connection received a message. */ DidReceive = 'bootloader.action.connection.did.receive', + /** + * Initiate disconnection/ + */ + Disconnect = 'bootloader.action.connection.disconnect', /** * The connection has been closed. */ @@ -59,6 +63,12 @@ export function didConnect(): BootloaderConnectionDidConnectAction { return { type: BootloaderConnectionActionType.DidConnect }; } +export type BootloaderConnectionDisconnectAction = Action; + +export function disconnect(): BootloaderConnectionDisconnectAction { + return { type: BootloaderConnectionActionType.Disconnect }; +} + /** * Possible reasons a device could fail to connect. */ diff --git a/src/components/notification-i18n.en.json b/src/components/notification-i18n.en.json index d0ae6e09..7555b749 100644 --- a/src/components/notification-i18n.en.json +++ b/src/components/notification-i18n.en.json @@ -13,6 +13,20 @@ "action": "Reload" } }, + "flashFirmware": { + "timedOut": "The hub took too long to respond. Restart the hub and try again.", + "bleError": "There was a problem with Bluetooth.", + "disconnected": "The hub was disconnected before flashing was completed. Restart the hub and try again.", + "hubError": "The hub said something went wrong.", + "unsupportedDevice": "The connected hub is not supported.", + "deviceMismatch": "The firmware is for a different kind of hub from the connected hub.", + "failToFetch": "Failed to fetch firmware from the server: {status}", + "badZipFile": "The firmware.zip file is missing required files or is corrupt.", + "badMetadata": "The firmware.metadata.py file contains missing or invalid entries. Fix it then try again.", + "compileError": "The included main.py file could not be compiled. Fix it then try again.", + "sizeTooBig": "The combined firmware and main.py are too big to fit in the flash memory.", + "unexpectedError": "Unexpected error while trying to flash firmware: {errorMessage}" + }, "mpy": { "error": "{errorMessage}" }, diff --git a/src/components/notification-i18n.ts b/src/components/notification-i18n.ts index 48414268..c519a1f6 100644 --- a/src/components/notification-i18n.ts +++ b/src/components/notification-i18n.ts @@ -10,6 +10,18 @@ export enum MessageId { BleGattPermission = 'ble.gattPermission', BleGattServiceNotFound = 'ble.gattServiceNotFound', BleNoWebBluetooth = 'ble.noWebBluetooth', + FlashFirmwareTimedOut = 'flashFirmware.timedOut', + FlashFirmwareBleError = 'flashFirmware.bleError', + FlashFirmwareDisconnected = 'flashFirmware.disconnected', + FlashFirmwareHubError = 'flashFirmware.hubError', + FlashFirmwareUnsupportedDevice = 'flashFirmware.unsupportedDevice', + FlashFirmwareDeviceMismatch = 'flashFirmware.deviceMismatch', + FlashFirmwareFailToFetch = 'flashFirmware.failToFetch', + FlashFirmwareBadZipFile = 'flashFirmware.badZipFile', + FlashFirmwareBadMetadata = 'flashFirmware.badMetadata', + FlashFirmwareCompileError = 'flashFirmware.compileError', + FlashFirmwareSizeTooBig = 'flashFirmware.sizeTooBig', + FlashFirmwareUnexpectedError = 'flashFirmware.unexpectedError', ProgramChangedMessage = 'editor.programChanged.message', ProgramChangedAction = 'editor.programChanged.action', ServiceWorkerUpdateMessage = 'serviceWorker.update.message', diff --git a/src/sagas/__snapshots__/flash-firmware.test.ts.snap b/src/sagas/__snapshots__/flash-firmware.test.ts.snap deleted file mode 100644 index 77e153cd..00000000 --- a/src/sagas/__snapshots__/flash-firmware.test.ts.snap +++ /dev/null @@ -1,31 +0,0 @@ -// Jest Snapshot v1, https://goo.gl/fbAQLP - -exports[`flashFirmware normal flow 1`] = ` -Object { - "options": Array [ - "-mno-unicode", - ], - "script": "print(\\"test\\")", - "type": "mpy.action.compile", -} -`; - -exports[`flashFirmware user supplied firmware.zip success 1`] = ` -Object { - "options": Array [ - "-mno-unicode", - ], - "script": "print(\\"test\\")", - "type": "mpy.action.compile", -} -`; - -exports[`flashFirmware user supplied main.py 1`] = ` -Object { - "options": Array [ - "-mno-unicode", - ], - "script": "print(\\"test\\")", - "type": "mpy.action.compile", -} -`; diff --git a/src/sagas/__snapshots__/mpy.test.ts.snap b/src/sagas/__snapshots__/mpy.test.ts.snap deleted file mode 100644 index 1e4167cd..00000000 --- a/src/sagas/__snapshots__/mpy.test.ts.snap +++ /dev/null @@ -1,9 +0,0 @@ -// Jest Snapshot v1, https://goo.gl/fbAQLP - -exports[`compiler error works 1`] = ` -Array [ - "Traceback (most recent call last):", - " File \\"main.py\\", line 1", - "SyntaxError: invalid syntax", -] -`; diff --git a/src/sagas/editor.test.ts b/src/sagas/editor.test.ts index e76570c6..c09aed41 100644 --- a/src/sagas/editor.test.ts +++ b/src/sagas/editor.test.ts @@ -11,11 +11,10 @@ jest.mock('ace-builds'); jest.mock('file-saver'); test('open', async () => { - const saga = new AsyncSaga(editor); const mockEditor = mock(); - const data = new Uint8Array().buffer; + const saga = new AsyncSaga(editor, { editor: { current: mockEditor } }); - saga.setState({ editor: { current: mockEditor } }); + const data = new Uint8Array().buffer; saga.put(open(data)); expect(mockEditor.setValue).toBeCalled(); @@ -24,10 +23,9 @@ test('open', async () => { }); test('saveAs', async () => { - const saga = new AsyncSaga(editor); const mockEditor = mock(); + const saga = new AsyncSaga(editor, { editor: { current: mockEditor } }); - saga.setState({ editor: { current: mockEditor } }); saga.put(saveAs()); expect(mockEditor.getValue).toBeCalled(); @@ -36,10 +34,9 @@ test('saveAs', async () => { }); test('reloadProgram', async () => { - const saga = new AsyncSaga(editor); const mockEditor = mock(); + const saga = new AsyncSaga(editor, { editor: { current: mockEditor } }); - saga.setState({ editor: { current: mockEditor } }); saga.put(reloadProgram()); expect(mockEditor.setValue).toHaveBeenCalled(); diff --git a/src/sagas/flash-firmware.test.ts b/src/sagas/flash-firmware.test.ts index cf63d4d6..2f356a60 100644 --- a/src/sagas/flash-firmware.test.ts +++ b/src/sagas/flash-firmware.test.ts @@ -9,22 +9,29 @@ import { import JSZip from 'jszip'; import { AsyncSaga } from '../../test'; import { - FailToStartReasonType, - didFailToStart, + FailToFinishReasonType, + HubError, + MetadataProblem, + didFailToFinish, didFinish, didProgress, didStart, flashFirmware as flashFirmwareAction, } from '../actions/flash-firmware'; import { + BootloaderConnectionFailureReason, BootloaderProgramRequestAction, checksumRequest, checksumResponse, connect, didConnect, + didDisconnect, + didFailToConnect, didRequest, + disconnect, eraseRequest, eraseResponse, + errorResponse, infoRequest, infoResponse, initRequest, @@ -33,8 +40,9 @@ import { programResponse, rebootRequest, } from '../actions/lwp3-bootloader'; -import { didCompile } from '../actions/mpy'; -import { HubType, Result } from '../protocols/lwp3-bootloader'; +import { didCompile, didFailToCompile } from '../actions/mpy'; +import { Command, HubType, Result } from '../protocols/lwp3-bootloader'; +import { BootloaderConnectionState } from '../reducers/bootloader'; import { createCountFunc } from '../utils/iter'; import flashFirmware from './flash-firmware'; @@ -43,135 +51,1240 @@ afterEach(() => { }); describe('flashFirmware', () => { - test('normal flow', async () => { - const metadata: FirmwareMetadata = { - 'metadata-version': '1.0.0', - 'device-id': HubType.MoveHub, - 'checksum-type': 'sum', - 'firmware-version': '1.2.3', - 'max-firmware-size': 1024, - 'mpy-abi-version': 5, - 'mpy-cross-options': ['-mno-unicode'], - 'user-mpy-offset': 100, - }; + describe('normal flow using app supplied firmware', () => { + test('success', async () => { + const metadata: FirmwareMetadata = { + 'metadata-version': '1.0.0', + 'device-id': HubType.MoveHub, + 'checksum-type': 'sum', + 'firmware-version': '1.2.3', + 'max-firmware-size': 1024, + 'mpy-abi-version': 5, + 'mpy-cross-options': ['-mno-unicode'], + 'user-mpy-offset': 100, + }; - const zip = new JSZip(); - zip.file('firmware-base.bin', new Uint8Array(64)); - zip.file('firmware.metadata.json', JSON.stringify(metadata)); - zip.file('main.py', 'print("test")'); - zip.file('ReadMe_OSS.txt', 'test'); + const zip = new JSZip(); + zip.file('firmware-base.bin', new Uint8Array(64)); + zip.file('firmware.metadata.json', JSON.stringify(metadata)); + zip.file('main.py', 'print("test")'); + zip.file('ReadMe_OSS.txt', 'test'); - jest.spyOn(window, 'fetch').mockResolvedValueOnce( - new Response(await zip.generateAsync({ type: 'blob' })), - ); + jest.spyOn(window, 'fetch').mockResolvedValueOnce( + new Response(await zip.generateAsync({ type: 'blob' })), + ); - const saga = new AsyncSaga(flashFirmware, { nextMessageId: createCountFunc() }); + const saga = new AsyncSaga( + flashFirmware, + { + bootloader: { connection: BootloaderConnectionState.Disconnected }, + settings: { flashCurrentProgram: false }, + }, + { + nextMessageId: createCountFunc(), + }, + ); - saga.setState({ settings: { flashCurrentProgram: false } }); + // saga is triggered by this action - // saga is triggered by this action + saga.put(flashFirmwareAction()); - saga.put(flashFirmwareAction()); + // first step is to connect to the hub bootloader - // first step is to connect to the hub bootloader + let action = await saga.take(); + expect(action).toEqual(connect()); - let action = await saga.take(); - expect(action).toEqual(connect()); + saga.updateState({ + bootloader: { connection: BootloaderConnectionState.Connected }, + }); + saga.put(didConnect()); - saga.put(didConnect()); + // then find out what kind of hub it is - // then find out what kind of hub it is - - action = await saga.take(); - expect(action).toEqual(infoRequest(0)); - - saga.put(didRequest(0)); - saga.put(infoResponse(0x01000000, 0x08005000, 0x081f800, HubType.MoveHub)); - - // then compile main.py to .mpy - - action = await saga.take(); - expect(action).toMatchSnapshot(); - - const mpySize = 20; - const mpyBinaryData = new Uint8Array(mpySize); - saga.put(didCompile(mpyBinaryData)); - - // then start flashing the firmware - - // should get didStart action just before starting to erase - action = await saga.take(); - expect(action).toEqual(didStart()); - - // erase first - - action = await saga.take(); - expect(action).toEqual(eraseRequest(1)); - - saga.put(didRequest(1)); - saga.put(eraseResponse(Result.OK)); - - // then write the new firmware - - const totalFirmwareSize = metadata['user-mpy-offset'] + mpySize + 8; - action = await saga.take(); - expect(action).toEqual(initRequest(2, totalFirmwareSize)); - - saga.put(didRequest(2)); - saga.put(initResponse(Result.OK)); - - const dummyPayload = new ArrayBuffer(0); - let id = 2; - for (let count = 1, offset = 0; ; count++, offset += 14) { action = await saga.take(); - expect(action).toEqual( - programRequest(++id, 0x08005000 + offset, dummyPayload), - ); - expect((action as BootloaderProgramRequestAction).payload.byteLength).toBe( - Math.min(14, totalFirmwareSize - offset), - ); + expect(action).toEqual(infoRequest(0)); + + saga.put(didRequest(0)); + saga.put(infoResponse(0x01000000, 0x08005000, 0x081f800, HubType.MoveHub)); + + // then compile main.py to .mpy + + action = await saga.take(); + expect(action).toMatchInlineSnapshot(` + Object { + "options": Array [ + "-mno-unicode", + ], + "script": "print(\\"test\\")", + "type": "mpy.action.compile", + } + `); + + // this makes total firmware size 140 bytes to check for + // https://github.com/pybricks/support/issues/178 + const mpySize = 40 - 8; + const mpyBinaryData = new Uint8Array(mpySize); + saga.put(didCompile(mpyBinaryData)); + + // then start flashing the firmware + + // should get didStart action just before starting to erase + action = await saga.take(); + expect(action).toEqual(didStart()); + + // erase first + + action = await saga.take(); + expect(action).toEqual(eraseRequest(1)); + + saga.put(didRequest(1)); + saga.put(eraseResponse(Result.OK)); + + // then write the new firmware + + const totalFirmwareSize = metadata['user-mpy-offset'] + mpySize + 8; + action = await saga.take(); + expect(action).toEqual(initRequest(2, totalFirmwareSize)); + + saga.put(didRequest(2)); + saga.put(initResponse(Result.OK)); + + const dummyPayload = new ArrayBuffer(0); + let id = 2; + for (let count = 1, offset = 0; ; count++, offset += 14) { + action = await saga.take(); + expect(action).toEqual( + programRequest(++id, 0x08005000 + offset, dummyPayload), + ); + expect( + (action as BootloaderProgramRequestAction).payload.byteLength, + ).toBe(Math.min(14, totalFirmwareSize - offset)); + + saga.put(didRequest(id)); + + action = await saga.take(); + expect(action).toEqual(didProgress(offset / totalFirmwareSize)); + + // Have to be careful that a checksum request is not sent after + // last payload is sent, otherwise the hub gets confused. + + if (offset + 14 >= totalFirmwareSize) { + expect(count).toBe(10); + break; + } + + if (count % 10 === 0) { + action = await saga.take(); + expect(action).toEqual(checksumRequest(++id)); + + saga.put(didRequest(id)); + saga.put(checksumResponse(0)); + } + } + + // hub indicates success + + saga.put(programResponse(0x62, totalFirmwareSize)); + + action = await saga.take(); + expect(action).toEqual(didProgress(1)); + + // and finally reboot the hub + + action = await saga.take(); + expect(action).toEqual(rebootRequest(++id)); saga.put(didRequest(id)); + // then we are done + action = await saga.take(); - expect(action).toEqual(didProgress(offset / totalFirmwareSize)); + expect(action).toEqual(didFinish()); - // Have to be careful that a checksum request is not sent after - // last payload is sent, otherwise the hub gets confused. + await saga.end(); + }); - if (offset + 14 >= totalFirmwareSize) { - break; - } + test('fail to connect', async () => { + const metadata: FirmwareMetadata = { + 'metadata-version': '1.0.0', + 'device-id': HubType.MoveHub, + 'checksum-type': 'sum', + 'firmware-version': '1.2.3', + 'max-firmware-size': 1024, + 'mpy-abi-version': 5, + 'mpy-cross-options': ['-mno-unicode'], + 'user-mpy-offset': 100, + }; - if (count % 10 === 0) { + const zip = new JSZip(); + zip.file('firmware-base.bin', new Uint8Array(64)); + zip.file('firmware.metadata.json', JSON.stringify(metadata)); + zip.file('main.py', 'print("test")'); + zip.file('ReadMe_OSS.txt', 'test'); + + jest.spyOn(window, 'fetch').mockResolvedValueOnce( + new Response(await zip.generateAsync({ type: 'blob' })), + ); + + const saga = new AsyncSaga( + flashFirmware, + { + bootloader: { connection: BootloaderConnectionState.Disconnected }, + settings: { flashCurrentProgram: false }, + }, + { + nextMessageId: createCountFunc(), + }, + ); + + // saga is triggered by this action + + saga.put(flashFirmwareAction()); + + // first step is to connect to the hub bootloader + + let action = await saga.take(); + expect(action).toEqual(connect()); + + saga.put( + didFailToConnect(BootloaderConnectionFailureReason.GattServiceNotFound), + ); + + // it should fail here because of failure to connect + + action = await saga.take(); + expect(action).toEqual( + didFailToFinish(FailToFinishReasonType.FailedToConnect), + ); + + await saga.end(); + }); + + test('untimely disconnect before start cancels saga', async () => { + const metadata: FirmwareMetadata = { + 'metadata-version': '1.0.0', + 'device-id': HubType.MoveHub, + 'checksum-type': 'sum', + 'firmware-version': '1.2.3', + 'max-firmware-size': 1024, + 'mpy-abi-version': 5, + 'mpy-cross-options': ['-mno-unicode'], + 'user-mpy-offset': 100, + }; + + const zip = new JSZip(); + zip.file('firmware-base.bin', new Uint8Array(64)); + zip.file('firmware.metadata.json', JSON.stringify(metadata)); + zip.file('main.py', 'print("test")'); + zip.file('ReadMe_OSS.txt', 'test'); + + jest.spyOn(window, 'fetch').mockResolvedValueOnce( + new Response(await zip.generateAsync({ type: 'blob' })), + ); + + const saga = new AsyncSaga( + flashFirmware, + { + bootloader: { connection: BootloaderConnectionState.Disconnected }, + settings: { flashCurrentProgram: false }, + }, + { + nextMessageId: createCountFunc(), + }, + ); + + // saga is triggered by this action + + saga.put(flashFirmwareAction()); + + // first step is to connect to the hub bootloader + + let action = await saga.take(); + expect(action).toEqual(connect()); + + saga.updateState({ + bootloader: { connection: BootloaderConnectionState.Connected }, + }); + saga.put(didConnect()); + + // then find out what kind of hub it is + + action = await saga.take(); + expect(action).toEqual(infoRequest(0)); + + // hub disconnects before replying + + saga.updateState({ + bootloader: { connection: BootloaderConnectionState.Disconnected }, + }); + saga.put(didDisconnect()); + + // should get a failure to start + + action = await saga.take(); + expect(action).toEqual( + didFailToFinish(FailToFinishReasonType.Disconnected), + ); + + // On city hub, we can end up in this situation. BLE writeValueWithResponse() + // doesn't return until erasing is done, so there is a long window for + // this to happen. + saga.put(didRequest(0, new Error('failed due to disconnect'))); + + await saga.end(); + }); + + test('fail to send info request', async () => { + const metadata: FirmwareMetadata = { + 'metadata-version': '1.0.0', + 'device-id': HubType.MoveHub, + 'checksum-type': 'sum', + 'firmware-version': '1.2.3', + 'max-firmware-size': 1024, + 'mpy-abi-version': 5, + 'mpy-cross-options': ['-mno-unicode'], + 'user-mpy-offset': 100, + }; + + const zip = new JSZip(); + zip.file('firmware-base.bin', new Uint8Array(64)); + zip.file('firmware.metadata.json', JSON.stringify(metadata)); + zip.file('main.py', 'print("test")'); + zip.file('ReadMe_OSS.txt', 'test'); + + jest.spyOn(window, 'fetch').mockResolvedValueOnce( + new Response(await zip.generateAsync({ type: 'blob' })), + ); + + const saga = new AsyncSaga( + flashFirmware, + { + bootloader: { connection: BootloaderConnectionState.Disconnected }, + settings: { flashCurrentProgram: false }, + }, + { + nextMessageId: createCountFunc(), + }, + ); + + // saga is triggered by this action + + saga.put(flashFirmwareAction()); + + // first step is to connect to the hub bootloader + + let action = await saga.take(); + expect(action).toEqual(connect()); + + saga.updateState({ + bootloader: { connection: BootloaderConnectionState.Connected }, + }); + saga.put(didConnect()); + + // then find out what kind of hub it is + + action = await saga.take(); + expect(action).toEqual(infoRequest(0)); + + const testError = new Error('test'); + saga.put(didRequest(0, testError)); + + // should get a failure to start + + action = await saga.take(); + expect(action).toEqual( + didFailToFinish(FailToFinishReasonType.BleError, testError), + ); + + // should request to disconnect after failure + + action = await saga.take(); + expect(action).toEqual(disconnect()); + + await saga.end(); + }); + + test('info request is unknown command', async () => { + const metadata: FirmwareMetadata = { + 'metadata-version': '1.0.0', + 'device-id': HubType.MoveHub, + 'checksum-type': 'sum', + 'firmware-version': '1.2.3', + 'max-firmware-size': 1024, + 'mpy-abi-version': 5, + 'mpy-cross-options': ['-mno-unicode'], + 'user-mpy-offset': 100, + }; + + const zip = new JSZip(); + zip.file('firmware-base.bin', new Uint8Array(64)); + zip.file('firmware.metadata.json', JSON.stringify(metadata)); + zip.file('main.py', 'print("test")'); + zip.file('ReadMe_OSS.txt', 'test'); + + jest.spyOn(window, 'fetch').mockResolvedValueOnce( + new Response(await zip.generateAsync({ type: 'blob' })), + ); + + const saga = new AsyncSaga( + flashFirmware, + { + bootloader: { connection: BootloaderConnectionState.Disconnected }, + settings: { flashCurrentProgram: false }, + }, + { + nextMessageId: createCountFunc(), + }, + ); + + // saga is triggered by this action + + saga.put(flashFirmwareAction()); + + // first step is to connect to the hub bootloader + + let action = await saga.take(); + expect(action).toEqual(connect()); + + saga.updateState({ + bootloader: { connection: BootloaderConnectionState.Connected }, + }); + saga.put(didConnect()); + + // then find out what kind of hub it is + + action = await saga.take(); + expect(action).toEqual(infoRequest(0)); + + saga.put(didRequest(0)); + saga.put(errorResponse(Command.GetInfo)); + + // should get an unknown command failure + + action = await saga.take(); + expect(action).toEqual( + didFailToFinish( + FailToFinishReasonType.HubError, + HubError.UnknownCommand, + ), + ); + + // should request to disconnect after failure + + action = await saga.take(); + expect(action).toEqual(disconnect()); + + await saga.end(); + }); + + test('timeout waiting for info response', async () => { + const metadata: FirmwareMetadata = { + 'metadata-version': '1.0.0', + 'device-id': HubType.MoveHub, + 'checksum-type': 'sum', + 'firmware-version': '1.2.3', + 'max-firmware-size': 1024, + 'mpy-abi-version': 5, + 'mpy-cross-options': ['-mno-unicode'], + 'user-mpy-offset': 100, + }; + + const zip = new JSZip(); + zip.file('firmware-base.bin', new Uint8Array(64)); + zip.file('firmware.metadata.json', JSON.stringify(metadata)); + zip.file('main.py', 'print("test")'); + zip.file('ReadMe_OSS.txt', 'test'); + + jest.spyOn(window, 'fetch').mockResolvedValueOnce( + new Response(await zip.generateAsync({ type: 'blob' })), + ); + + const saga = new AsyncSaga( + flashFirmware, + { + bootloader: { connection: BootloaderConnectionState.Disconnected }, + settings: { flashCurrentProgram: false }, + }, + { + nextMessageId: createCountFunc(), + }, + ); + + // saga is triggered by this action + + saga.put(flashFirmwareAction()); + + // first step is to connect to the hub bootloader + + let action = await saga.take(); + expect(action).toEqual(connect()); + + saga.updateState({ + bootloader: { connection: BootloaderConnectionState.Connected }, + }); + saga.put(didConnect()); + + // then find out what kind of hub it is + + action = await saga.take(); + expect(action).toEqual(infoRequest(0)); + + saga.put(didRequest(0)); + + // should get a timed-out failure + + action = await saga.take(); + expect(action).toEqual(didFailToFinish(FailToFinishReasonType.TimedOut)); + + // should request to disconnect after failure + + action = await saga.take(); + expect(action).toEqual(disconnect()); + + await saga.end(); + }); + + test('failed to fetch firmware', async () => { + const metadata: FirmwareMetadata = { + 'metadata-version': '1.0.0', + 'device-id': HubType.MoveHub, + 'checksum-type': 'sum', + 'firmware-version': '1.2.3', + 'max-firmware-size': 1024, + 'mpy-abi-version': 5, + 'mpy-cross-options': ['-mno-unicode'], + 'user-mpy-offset': 100, + }; + + const zip = new JSZip(); + zip.file('firmware-base.bin', new Uint8Array(64)); + zip.file('firmware.metadata.json', JSON.stringify(metadata)); + zip.file('main.py', 'print("test")'); + zip.file('ReadMe_OSS.txt', 'test'); + + const response = new Response(undefined, { status: 404 }); + jest.spyOn(window, 'fetch').mockResolvedValueOnce(response); + + const saga = new AsyncSaga( + flashFirmware, + { + bootloader: { connection: BootloaderConnectionState.Disconnected }, + settings: { flashCurrentProgram: false }, + }, + { + nextMessageId: createCountFunc(), + }, + ); + + // saga is triggered by this action + + saga.put(flashFirmwareAction()); + + // first step is to connect to the hub bootloader + + let action = await saga.take(); + expect(action).toEqual(connect()); + + saga.updateState({ + bootloader: { connection: BootloaderConnectionState.Connected }, + }); + saga.put(didConnect()); + + // then find out what kind of hub it is + + action = await saga.take(); + expect(action).toEqual(infoRequest(0)); + + // received an unknown hub type ID + + saga.put(didRequest(0)); + saga.put(infoResponse(0x01000000, 0x08005000, 0x081f800, HubType.MoveHub)); + + // should raise an error that we don't have any firmware for this hub + + action = await saga.take(); + expect(action).toStrictEqual( + didFailToFinish(FailToFinishReasonType.FailedToFetch, response), + ); + + // should request to disconnect after failure + + action = await saga.take(); + expect(action).toEqual(disconnect()); + + await saga.end(); + }); + + test('connected device does not match firmware device', async () => { + const metadata: FirmwareMetadata = { + 'metadata-version': '1.0.0', + 'device-id': HubType.MoveHub, + 'checksum-type': 'sum', + 'firmware-version': '1.2.3', + 'max-firmware-size': 1024, + 'mpy-abi-version': 5, + 'mpy-cross-options': ['-mno-unicode'], + 'user-mpy-offset': 100, + }; + + const zip = new JSZip(); + zip.file('firmware-base.bin', new Uint8Array(64)); + zip.file('firmware.metadata.json', JSON.stringify(metadata)); + zip.file('main.py', 'print("test")'); + zip.file('ReadMe_OSS.txt', 'test'); + + jest.spyOn(window, 'fetch').mockResolvedValueOnce( + new Response(await zip.generateAsync({ type: 'blob' })), + ); + + const saga = new AsyncSaga( + flashFirmware, + { + bootloader: { connection: BootloaderConnectionState.Disconnected }, + settings: { flashCurrentProgram: false }, + }, + { + nextMessageId: createCountFunc(), + }, + ); + + // saga is triggered by this action + + saga.put(flashFirmwareAction()); + + // first step is to connect to the hub bootloader + + let action = await saga.take(); + expect(action).toEqual(connect()); + + saga.updateState({ + bootloader: { connection: BootloaderConnectionState.Connected }, + }); + saga.put(didConnect()); + + // then find out what kind of hub it is + + action = await saga.take(); + expect(action).toEqual(infoRequest(0)); + + // connected hub type does not match firmware hub type + saga.put(didRequest(0)); + saga.put(infoResponse(0x01000000, 0x08005000, 0x081f800, HubType.CityHub)); + + // then compile main.py to .mpy + + action = await saga.take(); + expect(action).toMatchInlineSnapshot(` + Object { + "options": Array [ + "-mno-unicode", + ], + "script": "print(\\"test\\")", + "type": "mpy.action.compile", + } + `); + + const mpySize = 20; + const mpyBinaryData = new Uint8Array(mpySize); + saga.put(didCompile(mpyBinaryData)); + + // should raise an error that we don't have any firmware for this hub + + action = await saga.take(); + expect(action).toStrictEqual( + didFailToFinish(FailToFinishReasonType.DeviceMismatch), + ); + + // should request to disconnect after failure + + action = await saga.take(); + expect(action).toEqual(disconnect()); + + await saga.end(); + }); + + test('unsupported device', async () => { + const metadata: FirmwareMetadata = { + 'metadata-version': '1.0.0', + 'device-id': HubType.MoveHub, + 'checksum-type': 'sum', + 'firmware-version': '1.2.3', + 'max-firmware-size': 1024, + 'mpy-abi-version': 5, + 'mpy-cross-options': ['-mno-unicode'], + 'user-mpy-offset': 100, + }; + + const zip = new JSZip(); + zip.file('firmware-base.bin', new Uint8Array(64)); + zip.file('firmware.metadata.json', JSON.stringify(metadata)); + zip.file('main.py', 'print("test")'); + zip.file('ReadMe_OSS.txt', 'test'); + + jest.spyOn(window, 'fetch').mockResolvedValueOnce( + new Response(await zip.generateAsync({ type: 'blob' })), + ); + + const saga = new AsyncSaga( + flashFirmware, + { + bootloader: { connection: BootloaderConnectionState.Disconnected }, + settings: { flashCurrentProgram: false }, + }, + { + nextMessageId: createCountFunc(), + }, + ); + + // saga is triggered by this action + + saga.put(flashFirmwareAction()); + + // first step is to connect to the hub bootloader + + let action = await saga.take(); + expect(action).toEqual(connect()); + + saga.updateState({ + bootloader: { connection: BootloaderConnectionState.Connected }, + }); + saga.put(didConnect()); + + // then find out what kind of hub it is + + action = await saga.take(); + expect(action).toEqual(infoRequest(0)); + + // received an unknown hub type ID + + saga.put(didRequest(0)); + saga.put(infoResponse(0x01000000, 0x08005000, 0x081f800, 0 as HubType)); + + // should raise an error that we don't have any firmware for this hub + + action = await saga.take(); + expect(action).toStrictEqual( + didFailToFinish(FailToFinishReasonType.NoFirmware), + ); + + // should request to disconnect after failure + + action = await saga.take(); + expect(action).toEqual(disconnect()); + + await saga.end(); + }); + + test('erase response is failed', async () => { + const metadata: FirmwareMetadata = { + 'metadata-version': '1.0.0', + 'device-id': HubType.MoveHub, + 'checksum-type': 'sum', + 'firmware-version': '1.2.3', + 'max-firmware-size': 1024, + 'mpy-abi-version': 5, + 'mpy-cross-options': ['-mno-unicode'], + 'user-mpy-offset': 100, + }; + + const zip = new JSZip(); + zip.file('firmware-base.bin', new Uint8Array(64)); + zip.file('firmware.metadata.json', JSON.stringify(metadata)); + zip.file('main.py', 'print("test")'); + zip.file('ReadMe_OSS.txt', 'test'); + + jest.spyOn(window, 'fetch').mockResolvedValueOnce( + new Response(await zip.generateAsync({ type: 'blob' })), + ); + + const saga = new AsyncSaga( + flashFirmware, + { + bootloader: { connection: BootloaderConnectionState.Disconnected }, + settings: { flashCurrentProgram: false }, + }, + { + nextMessageId: createCountFunc(), + }, + ); + + // saga is triggered by this action + + saga.put(flashFirmwareAction()); + + // first step is to connect to the hub bootloader + + let action = await saga.take(); + expect(action).toEqual(connect()); + + saga.updateState({ + bootloader: { connection: BootloaderConnectionState.Connected }, + }); + saga.put(didConnect()); + + // then find out what kind of hub it is + + action = await saga.take(); + expect(action).toEqual(infoRequest(0)); + + saga.put(didRequest(0)); + saga.put(infoResponse(0x01000000, 0x08005000, 0x081f800, HubType.MoveHub)); + + // then compile main.py to .mpy + + action = await saga.take(); + expect(action).toMatchInlineSnapshot(` + Object { + "options": Array [ + "-mno-unicode", + ], + "script": "print(\\"test\\")", + "type": "mpy.action.compile", + } + `); + + const mpySize = 20; + const mpyBinaryData = new Uint8Array(mpySize); + saga.put(didCompile(mpyBinaryData)); + + // then start flashing the firmware + + // should get didStart action just before starting to erase + action = await saga.take(); + expect(action).toEqual(didStart()); + + // erase first + + action = await saga.take(); + expect(action).toEqual(eraseRequest(1)); + + saga.put(didRequest(1)); + saga.put(eraseResponse(Result.Error)); + + // should get a hub error + + action = await saga.take(); + expect(action).toEqual( + didFailToFinish(FailToFinishReasonType.HubError, HubError.EraseFailed), + ); + + // should request to disconnect after failure + + action = await saga.take(); + expect(action).toEqual(disconnect()); + + await saga.end(); + }); + + test('init response is failed', async () => { + const metadata: FirmwareMetadata = { + 'metadata-version': '1.0.0', + 'device-id': HubType.MoveHub, + 'checksum-type': 'sum', + 'firmware-version': '1.2.3', + 'max-firmware-size': 1024, + 'mpy-abi-version': 5, + 'mpy-cross-options': ['-mno-unicode'], + 'user-mpy-offset': 100, + }; + + const zip = new JSZip(); + zip.file('firmware-base.bin', new Uint8Array(64)); + zip.file('firmware.metadata.json', JSON.stringify(metadata)); + zip.file('main.py', 'print("test")'); + zip.file('ReadMe_OSS.txt', 'test'); + + jest.spyOn(window, 'fetch').mockResolvedValueOnce( + new Response(await zip.generateAsync({ type: 'blob' })), + ); + + const saga = new AsyncSaga( + flashFirmware, + { + bootloader: { connection: BootloaderConnectionState.Disconnected }, + settings: { flashCurrentProgram: false }, + }, + { + nextMessageId: createCountFunc(), + }, + ); + + // saga is triggered by this action + + saga.put(flashFirmwareAction()); + + // first step is to connect to the hub bootloader + + let action = await saga.take(); + expect(action).toEqual(connect()); + + saga.updateState({ + bootloader: { connection: BootloaderConnectionState.Connected }, + }); + saga.put(didConnect()); + + // then find out what kind of hub it is + + action = await saga.take(); + expect(action).toEqual(infoRequest(0)); + + saga.put(didRequest(0)); + saga.put(infoResponse(0x01000000, 0x08005000, 0x081f800, HubType.MoveHub)); + + // then compile main.py to .mpy + + action = await saga.take(); + expect(action).toMatchInlineSnapshot(` + Object { + "options": Array [ + "-mno-unicode", + ], + "script": "print(\\"test\\")", + "type": "mpy.action.compile", + } + `); + + const mpySize = 20; + const mpyBinaryData = new Uint8Array(mpySize); + saga.put(didCompile(mpyBinaryData)); + + // then start flashing the firmware + + // should get didStart action just before starting to erase + action = await saga.take(); + expect(action).toEqual(didStart()); + + // erase first + + action = await saga.take(); + expect(action).toEqual(eraseRequest(1)); + + saga.put(didRequest(1)); + saga.put(eraseResponse(Result.OK)); + + // then write the new firmware + + const totalFirmwareSize = metadata['user-mpy-offset'] + mpySize + 8; + action = await saga.take(); + expect(action).toEqual(initRequest(2, totalFirmwareSize)); + + saga.put(didRequest(2)); + saga.put(initResponse(Result.Error)); + + // should get a hub error + + action = await saga.take(); + expect(action).toEqual( + didFailToFinish(FailToFinishReasonType.HubError, HubError.InitFailed), + ); + + // should request to disconnect after failure + + action = await saga.take(); + expect(action).toEqual(disconnect()); + + await saga.end(); + }); + + test('size mismatch after flashing', async () => { + const metadata: FirmwareMetadata = { + 'metadata-version': '1.0.0', + 'device-id': HubType.MoveHub, + 'checksum-type': 'sum', + 'firmware-version': '1.2.3', + 'max-firmware-size': 1024, + 'mpy-abi-version': 5, + 'mpy-cross-options': ['-mno-unicode'], + 'user-mpy-offset': 100, + }; + + const zip = new JSZip(); + zip.file('firmware-base.bin', new Uint8Array(64)); + zip.file('firmware.metadata.json', JSON.stringify(metadata)); + zip.file('main.py', 'print("test")'); + zip.file('ReadMe_OSS.txt', 'test'); + + jest.spyOn(window, 'fetch').mockResolvedValueOnce( + new Response(await zip.generateAsync({ type: 'blob' })), + ); + + const saga = new AsyncSaga( + flashFirmware, + { + bootloader: { connection: BootloaderConnectionState.Disconnected }, + settings: { flashCurrentProgram: false }, + }, + { + nextMessageId: createCountFunc(), + }, + ); + + // saga is triggered by this action + + saga.put(flashFirmwareAction()); + + // first step is to connect to the hub bootloader + + let action = await saga.take(); + expect(action).toEqual(connect()); + + saga.updateState({ + bootloader: { connection: BootloaderConnectionState.Connected }, + }); + saga.put(didConnect()); + + // then find out what kind of hub it is + + action = await saga.take(); + expect(action).toEqual(infoRequest(0)); + + saga.put(didRequest(0)); + saga.put(infoResponse(0x01000000, 0x08005000, 0x081f800, HubType.MoveHub)); + + // then compile main.py to .mpy + + action = await saga.take(); + expect(action).toMatchInlineSnapshot(` + Object { + "options": Array [ + "-mno-unicode", + ], + "script": "print(\\"test\\")", + "type": "mpy.action.compile", + } + `); + + const mpySize = 20; + const mpyBinaryData = new Uint8Array(mpySize); + saga.put(didCompile(mpyBinaryData)); + + // then start flashing the firmware + + // should get didStart action just before starting to erase + action = await saga.take(); + expect(action).toEqual(didStart()); + + // erase first + + action = await saga.take(); + expect(action).toEqual(eraseRequest(1)); + + saga.put(didRequest(1)); + saga.put(eraseResponse(Result.OK)); + + // then write the new firmware + + const totalFirmwareSize = metadata['user-mpy-offset'] + mpySize + 8; + action = await saga.take(); + expect(action).toEqual(initRequest(2, totalFirmwareSize)); + + saga.put(didRequest(2)); + saga.put(initResponse(Result.OK)); + + const dummyPayload = new ArrayBuffer(0); + let id = 2; + for (let count = 1, offset = 0; ; count++, offset += 14) { action = await saga.take(); - expect(action).toEqual(checksumRequest(++id)); + expect(action).toEqual( + programRequest(++id, 0x08005000 + offset, dummyPayload), + ); + expect( + (action as BootloaderProgramRequestAction).payload.byteLength, + ).toBe(Math.min(14, totalFirmwareSize - offset)); saga.put(didRequest(id)); - saga.put(checksumResponse(0)); + + action = await saga.take(); + expect(action).toEqual(didProgress(offset / totalFirmwareSize)); + + // Have to be careful that a checksum request is not sent after + // last payload is sent, otherwise the hub gets confused. + + if (offset + 14 >= totalFirmwareSize) { + expect(count).toBe(10); + break; + } + + if (count % 10 === 0) { + action = await saga.take(); + expect(action).toEqual(checksumRequest(++id)); + + saga.put(didRequest(id)); + saga.put(checksumResponse(0)); + } } - } - // hub indicates success + // hub indicates incorrect size - saga.put(programResponse(0, totalFirmwareSize)); + saga.put(programResponse(0x62, totalFirmwareSize - 1)); - action = await saga.take(); - expect(action).toEqual(didProgress(1)); + // should get a hub error - // and finally reboot the hub + action = await saga.take(); + expect(action).toEqual( + didFailToFinish( + FailToFinishReasonType.HubError, + HubError.CountMismatch, + ), + ); - action = await saga.take(); - expect(action).toEqual(rebootRequest(++id)); + // should request to disconnect after failure - saga.put(didRequest(id)); + action = await saga.take(); + expect(action).toEqual(disconnect()); - // then we are done + await saga.end(); + }); - action = await saga.take(); - expect(action).toEqual(didFinish()); + test('checksum mismatch after flashing', async () => { + const metadata: FirmwareMetadata = { + 'metadata-version': '1.0.0', + 'device-id': HubType.MoveHub, + 'checksum-type': 'sum', + 'firmware-version': '1.2.3', + 'max-firmware-size': 1024, + 'mpy-abi-version': 5, + 'mpy-cross-options': ['-mno-unicode'], + 'user-mpy-offset': 100, + }; - await saga.end(); + const zip = new JSZip(); + zip.file('firmware-base.bin', new Uint8Array(64)); + zip.file('firmware.metadata.json', JSON.stringify(metadata)); + zip.file('main.py', 'print("test")'); + zip.file('ReadMe_OSS.txt', 'test'); + + jest.spyOn(window, 'fetch').mockResolvedValueOnce( + new Response(await zip.generateAsync({ type: 'blob' })), + ); + + const saga = new AsyncSaga( + flashFirmware, + { + bootloader: { connection: BootloaderConnectionState.Disconnected }, + settings: { flashCurrentProgram: false }, + }, + { + nextMessageId: createCountFunc(), + }, + ); + + // saga is triggered by this action + + saga.put(flashFirmwareAction()); + + // first step is to connect to the hub bootloader + + let action = await saga.take(); + expect(action).toEqual(connect()); + + saga.updateState({ + bootloader: { connection: BootloaderConnectionState.Connected }, + }); + saga.put(didConnect()); + + // then find out what kind of hub it is + + action = await saga.take(); + expect(action).toEqual(infoRequest(0)); + + saga.put(didRequest(0)); + saga.put(infoResponse(0x01000000, 0x08005000, 0x081f800, HubType.MoveHub)); + + // then compile main.py to .mpy + + action = await saga.take(); + expect(action).toMatchInlineSnapshot(` + Object { + "options": Array [ + "-mno-unicode", + ], + "script": "print(\\"test\\")", + "type": "mpy.action.compile", + } + `); + + const mpySize = 20; + const mpyBinaryData = new Uint8Array(mpySize); + saga.put(didCompile(mpyBinaryData)); + + // then start flashing the firmware + + // should get didStart action just before starting to erase + action = await saga.take(); + expect(action).toEqual(didStart()); + + // erase first + + action = await saga.take(); + expect(action).toEqual(eraseRequest(1)); + + saga.put(didRequest(1)); + saga.put(eraseResponse(Result.OK)); + + // then write the new firmware + + const totalFirmwareSize = metadata['user-mpy-offset'] + mpySize + 8; + action = await saga.take(); + expect(action).toEqual(initRequest(2, totalFirmwareSize)); + + saga.put(didRequest(2)); + saga.put(initResponse(Result.OK)); + + const dummyPayload = new ArrayBuffer(0); + let id = 2; + for (let count = 1, offset = 0; ; count++, offset += 14) { + action = await saga.take(); + expect(action).toEqual( + programRequest(++id, 0x08005000 + offset, dummyPayload), + ); + expect( + (action as BootloaderProgramRequestAction).payload.byteLength, + ).toBe(Math.min(14, totalFirmwareSize - offset)); + + saga.put(didRequest(id)); + + action = await saga.take(); + expect(action).toEqual(didProgress(offset / totalFirmwareSize)); + + // Have to be careful that a checksum request is not sent after + // last payload is sent, otherwise the hub gets confused. + + if (offset + 14 >= totalFirmwareSize) { + expect(count).toBe(10); + break; + } + + if (count % 10 === 0) { + action = await saga.take(); + expect(action).toEqual(checksumRequest(++id)); + + saga.put(didRequest(id)); + saga.put(checksumResponse(0)); + } + } + + // hub indicates incorrect checksum + + saga.put(programResponse(0x100, totalFirmwareSize)); + + // should get a hub error + + action = await saga.take(); + expect(action).toEqual( + didFailToFinish( + FailToFinishReasonType.HubError, + HubError.ChecksumMismatch, + ), + ); + + // should request to disconnect after failure + + action = await saga.take(); + expect(action).toEqual(disconnect()); + + await saga.end(); + }); }); describe('user supplied firmware.zip', () => { @@ -193,11 +1306,16 @@ describe('flashFirmware', () => { zip.file('main.py', 'print("test")'); zip.file('ReadMe_OSS.txt', 'test'); - const saga = new AsyncSaga(flashFirmware, { - nextMessageId: createCountFunc(), - }); - - saga.setState({ settings: { flashCurrentProgram: false } }); + const saga = new AsyncSaga( + flashFirmware, + { + bootloader: { connection: BootloaderConnectionState.Disconnected }, + settings: { flashCurrentProgram: false }, + }, + { + nextMessageId: createCountFunc(), + }, + ); // saga is triggered by this action @@ -208,9 +1326,19 @@ describe('flashFirmware', () => { // the first step is to compile main.py to .mpy let action = await saga.take(); - expect(action).toMatchSnapshot(); + expect(action).toMatchInlineSnapshot(` + Object { + "options": Array [ + "-mno-unicode", + ], + "script": "print(\\"test\\")", + "type": "mpy.action.compile", + } + `); - const mpySize = 20; + // make sure that total firmware size is big enough that checksum + // is called at least once + const mpySize = 100; const mpyBinaryData = new Uint8Array(mpySize); saga.put(didCompile(mpyBinaryData)); @@ -219,6 +1347,9 @@ describe('flashFirmware', () => { action = await saga.take(); expect(action).toEqual(connect()); + saga.updateState({ + bootloader: { connection: BootloaderConnectionState.Connected }, + }); saga.put(didConnect()); // then find out what kind of hub it is @@ -272,6 +1403,7 @@ describe('flashFirmware', () => { // last payload is sent, otherwise the hub gets confused. if (offset + 14 >= totalFirmwareSize) { + expect(count).toBeGreaterThan(10); break; } @@ -286,7 +1418,7 @@ describe('flashFirmware', () => { // hub indicates success - saga.put(programResponse(0, totalFirmwareSize)); + saga.put(programResponse(0xf3, totalFirmwareSize)); action = await saga.take(); expect(action).toEqual(didProgress(1)); @@ -324,11 +1456,16 @@ describe('flashFirmware', () => { zip.file('main.py', 'print("test")'); zip.file('ReadMe_OSS.txt', 'test'); - const saga = new AsyncSaga(flashFirmware, { - nextMessageId: createCountFunc(), - }); - - saga.setState({ settings: { flashCurrentProgram: false } }); + const saga = new AsyncSaga( + flashFirmware, + { + bootloader: { connection: BootloaderConnectionState.Disconnected }, + settings: { flashCurrentProgram: false }, + }, + { + nextMessageId: createCountFunc(), + }, + ); // saga is triggered by this action @@ -340,8 +1477,8 @@ describe('flashFirmware', () => { const action = await saga.take(); expect(action).toStrictEqual( - didFailToStart( - FailToStartReasonType.ZipError, + didFailToFinish( + FailToFinishReasonType.ZipError, new FirmwareReaderError( FirmwareReaderErrorCode.MissingFirmwareBaseBin, ), @@ -350,6 +1487,332 @@ describe('flashFirmware', () => { await saga.end(); }); + + test('unsupported mpy-cross version', async () => { + const metadata: FirmwareMetadata = { + 'metadata-version': '1.0.0', + 'device-id': HubType.MoveHub, + 'checksum-type': 'sum', + 'firmware-version': '1.2.3', + 'max-firmware-size': 1024, + 'mpy-abi-version': 4, // unsupported version + 'mpy-cross-options': ['-mno-unicode'], + 'user-mpy-offset': 100, + }; + + const zip = new JSZip(); + zip.file('firmware-base.bin', new Uint8Array(64)); + zip.file('firmware.metadata.json', JSON.stringify(metadata)); + zip.file('main.py', 'print("test")'); + zip.file('ReadMe_OSS.txt', 'test'); + + const saga = new AsyncSaga( + flashFirmware, + { + bootloader: { connection: BootloaderConnectionState.Disconnected }, + settings: { flashCurrentProgram: false }, + }, + { + nextMessageId: createCountFunc(), + }, + ); + + // saga is triggered by this action + + saga.put( + flashFirmwareAction(await zip.generateAsync({ type: 'arraybuffer' })), + ); + + // should get failure due to unsupported mpy-cross version + + const action = await saga.take(); + expect(action).toStrictEqual( + didFailToFinish( + FailToFinishReasonType.BadMetadata, + 'mpy-abi-version', + MetadataProblem.NotSupported, + ), + ); + + await saga.end(); + }); + + test('compile error', async () => { + const metadata: FirmwareMetadata = { + 'metadata-version': '1.0.0', + 'device-id': HubType.MoveHub, + 'checksum-type': 'sum', + 'firmware-version': '1.2.3', + 'max-firmware-size': 1024, + 'mpy-abi-version': 5, + 'mpy-cross-options': ['-mno-unicode'], + 'user-mpy-offset': 100, + }; + + const zip = new JSZip(); + zip.file('firmware-base.bin', new Uint8Array(64)); + zip.file('firmware.metadata.json', JSON.stringify(metadata)); + zip.file('main.py', 'print("test")'); + zip.file('ReadMe_OSS.txt', 'test'); + + const saga = new AsyncSaga( + flashFirmware, + { + bootloader: { connection: BootloaderConnectionState.Disconnected }, + settings: { flashCurrentProgram: false }, + }, + { + nextMessageId: createCountFunc(), + }, + ); + + // saga is triggered by this action + + saga.put( + flashFirmwareAction(await zip.generateAsync({ type: 'arraybuffer' })), + ); + + // the first step is to compile main.py to .mpy + + let action = await saga.take(); + expect(action).toMatchInlineSnapshot(` + Object { + "options": Array [ + "-mno-unicode", + ], + "script": "print(\\"test\\")", + "type": "mpy.action.compile", + } + `); + + // this triggers a failure + + saga.put(didFailToCompile(['test'])); + + // compiler error should trigger firmware flash failure + + action = await saga.take(); + expect(action).toEqual( + didFailToFinish(FailToFinishReasonType.FailedToCompile), + ); + + await saga.end(); + }); + + test('firmware too big', async () => { + const metadata: FirmwareMetadata = { + 'metadata-version': '1.0.0', + 'device-id': HubType.MoveHub, + 'checksum-type': 'sum', + 'firmware-version': '1.2.3', + 'max-firmware-size': 1, // low limit to trigger error + 'mpy-abi-version': 5, + 'mpy-cross-options': ['-mno-unicode'], + 'user-mpy-offset': 100, + }; + + const zip = new JSZip(); + zip.file('firmware-base.bin', new Uint8Array(64)); + zip.file('firmware.metadata.json', JSON.stringify(metadata)); + zip.file('main.py', 'print("test")'); + zip.file('ReadMe_OSS.txt', 'test'); + + const saga = new AsyncSaga( + flashFirmware, + { + bootloader: { connection: BootloaderConnectionState.Disconnected }, + settings: { flashCurrentProgram: false }, + }, + { + nextMessageId: createCountFunc(), + }, + ); + + // saga is triggered by this action + + saga.put( + flashFirmwareAction(await zip.generateAsync({ type: 'arraybuffer' })), + ); + + // the first step is to compile main.py to .mpy + + let action = await saga.take(); + expect(action).toMatchInlineSnapshot(` + Object { + "options": Array [ + "-mno-unicode", + ], + "script": "print(\\"test\\")", + "type": "mpy.action.compile", + } + `); + + const mpySize = 20; + const mpyBinaryData = new Uint8Array(mpySize); + saga.put(didCompile(mpyBinaryData)); + + // should fail due to firmware being too big + + action = await saga.take(); + expect(action).toEqual( + didFailToFinish(FailToFinishReasonType.FirmwareSize), + ); + + await saga.end(); + }); + + test('bad checksum algorithm', async () => { + const metadata: FirmwareMetadata = { + 'metadata-version': '1.0.0', + 'device-id': HubType.MoveHub, + // @ts-expect-error: testing bad value + 'checksum-type': 'bad', + 'firmware-version': '1.2.3', + 'max-firmware-size': 1024, + 'mpy-abi-version': 5, + 'mpy-cross-options': ['-mno-unicode'], + 'user-mpy-offset': 100, + }; + + const zip = new JSZip(); + zip.file('firmware-base.bin', new Uint8Array(64)); + zip.file('firmware.metadata.json', JSON.stringify(metadata)); + zip.file('main.py', 'print("test")'); + zip.file('ReadMe_OSS.txt', 'test'); + + const saga = new AsyncSaga( + flashFirmware, + { + bootloader: { connection: BootloaderConnectionState.Disconnected }, + settings: { flashCurrentProgram: false }, + }, + { + nextMessageId: createCountFunc(), + }, + ); + + // saga is triggered by this action + + saga.put( + flashFirmwareAction(await zip.generateAsync({ type: 'arraybuffer' })), + ); + + // the first step is to compile main.py to .mpy + + let action = await saga.take(); + expect(action).toMatchInlineSnapshot(` + Object { + "options": Array [ + "-mno-unicode", + ], + "script": "print(\\"test\\")", + "type": "mpy.action.compile", + } + `); + + const mpySize = 20; + const mpyBinaryData = new Uint8Array(mpySize); + saga.put(didCompile(mpyBinaryData)); + + // should fail due to bad checksum algorithm + + action = await saga.take(); + expect(action).toEqual( + didFailToFinish( + FailToFinishReasonType.BadMetadata, + 'checksum-type', + MetadataProblem.NotSupported, + ), + ); + + await saga.end(); + }); + + test('connected device type does not match firmware device type', async () => { + const metadata: FirmwareMetadata = { + 'metadata-version': '1.0.0', + 'device-id': HubType.MoveHub, + 'checksum-type': 'sum', + 'firmware-version': '1.2.3', + 'max-firmware-size': 1024, + 'mpy-abi-version': 5, + 'mpy-cross-options': ['-mno-unicode'], + 'user-mpy-offset': 100, + }; + + const zip = new JSZip(); + zip.file('firmware-base.bin', new Uint8Array(64)); + zip.file('firmware.metadata.json', JSON.stringify(metadata)); + zip.file('main.py', 'print("test")'); + zip.file('ReadMe_OSS.txt', 'test'); + + const saga = new AsyncSaga( + flashFirmware, + { + bootloader: { connection: BootloaderConnectionState.Disconnected }, + settings: { flashCurrentProgram: false }, + }, + { + nextMessageId: createCountFunc(), + }, + ); + + // saga is triggered by this action + + saga.put( + flashFirmwareAction(await zip.generateAsync({ type: 'arraybuffer' })), + ); + + // the first step is to compile main.py to .mpy + + let action = await saga.take(); + expect(action).toMatchInlineSnapshot(` + Object { + "options": Array [ + "-mno-unicode", + ], + "script": "print(\\"test\\")", + "type": "mpy.action.compile", + } + `); + + const mpySize = 20; + const mpyBinaryData = new Uint8Array(mpySize); + saga.put(didCompile(mpyBinaryData)); + + // then connect to the hub bootloader + + action = await saga.take(); + expect(action).toEqual(connect()); + + saga.updateState({ + bootloader: { connection: BootloaderConnectionState.Connected }, + }); + saga.put(didConnect()); + + // then find out what kind of hub it is + + action = await saga.take(); + expect(action).toEqual(infoRequest(0)); + + // connected hub type does not match firmware hub type + saga.put(didRequest(0)); + saga.put(infoResponse(0x01000000, 0x08005000, 0x081f800, HubType.CityHub)); + + // should raise an error that we don't have any firmware for this hub + + action = await saga.take(); + expect(action).toStrictEqual( + didFailToFinish(FailToFinishReasonType.DeviceMismatch), + ); + + // should request to disconnect after failure + + action = await saga.take(); + expect(action).toEqual(disconnect()); + + await saga.end(); + }); }); test('user supplied main.py', async () => { @@ -378,12 +1841,15 @@ describe('flashFirmware', () => { getValue: () => 'print("test")', }; - const saga = new AsyncSaga(flashFirmware, { nextMessageId: createCountFunc() }); - - saga.setState({ - editor: { current: editor }, - settings: { flashCurrentProgram: true }, - }); + const saga = new AsyncSaga( + flashFirmware, + { + bootloader: { connection: BootloaderConnectionState.Disconnected }, + editor: { current: editor }, + settings: { flashCurrentProgram: true }, + }, + { nextMessageId: createCountFunc() }, + ); // saga is triggered by this action @@ -394,6 +1860,9 @@ describe('flashFirmware', () => { let action = await saga.take(); expect(action).toEqual(connect()); + saga.updateState({ + bootloader: { connection: BootloaderConnectionState.Connected }, + }); saga.put(didConnect()); // then find out what kind of hub it is @@ -407,7 +1876,15 @@ describe('flashFirmware', () => { // then compile main.py to .mpy action = await saga.take(); - expect(action).toMatchSnapshot(); + expect(action).toMatchInlineSnapshot(` + Object { + "options": Array [ + "-mno-unicode", + ], + "script": "print(\\"test\\")", + "type": "mpy.action.compile", + } + `); const mpySize = 20; const mpyBinaryData = new Uint8Array(mpySize); @@ -470,7 +1947,7 @@ describe('flashFirmware', () => { // hub indicates success - saga.put(programResponse(0, totalFirmwareSize)); + saga.put(programResponse(0x27, totalFirmwareSize)); action = await saga.take(); expect(action).toEqual(didProgress(1)); diff --git a/src/sagas/flash-firmware.ts b/src/sagas/flash-firmware.ts index c6bc1ff1..84961607 100644 --- a/src/sagas/flash-firmware.ts +++ b/src/sagas/flash-firmware.ts @@ -20,19 +20,20 @@ import { } from 'typed-redux-saga/macro'; import { Action } from '../actions'; import { - FailToStartReasonType, + FailToFinishReasonType, FlashFirmwareActionType, FlashFirmwareFlashAction, - didFailToStart, + HubError, + MetadataProblem, + didFailToFinish, didFinish, didProgress, didStart, } from '../actions/flash-firmware'; import { BootloaderChecksumResponseAction, + BootloaderConnectionAction, BootloaderConnectionActionType, - BootloaderConnectionDidConnectAction, - BootloaderConnectionDidFailToConnectAction, BootloaderDidRequestAction, BootloaderDidRequestType, BootloaderEraseResponseAction, @@ -44,7 +45,7 @@ import { BootloaderResponseActionType, checksumRequest, connect, - disconnectRequest, + disconnect, eraseRequest, infoRequest, initRequest, @@ -57,9 +58,9 @@ import { MpyDidFailToCompileAction, compile, } from '../actions/mpy'; -import * as notification from '../actions/notification'; -import { MaxProgramFlashSize } from '../protocols/lwp3-bootloader'; +import { MaxProgramFlashSize, Result } from '../protocols/lwp3-bootloader'; import { RootState } from '../reducers'; +import { BootloaderConnectionState } from '../reducers/bootloader'; import { defined, maybe } from '../utils'; import { fmod, sumComplement32 } from '../utils/math'; @@ -69,10 +70,30 @@ const firmwareZipMap = new Map([ [HubType.MoveHub, moveHubZip], ]); +/** + * Disconnects the BLE if we are connected and cancels the task (including the + * parent task). + */ +function* disconnectAndCancel(): SagaGenerator { + const connection = yield* select((s: RootState) => s.bootloader.connection); + + if (connection === BootloaderConnectionState.Connected) { + yield* put(disconnect()); + } + + yield* cancel(); +} + function* waitForDidRequest(id: number): SagaGenerator { - return yield* take( + const request = yield* take( (a: Action) => a.type === BootloaderDidRequestType && a.id === id, ); + if (request.err) { + yield* put(didFailToFinish(FailToFinishReasonType.BleError, request.err)); + yield* disconnectAndCancel(); + } + + return request; } /** @@ -84,16 +105,34 @@ function* waitForDidRequest(id: number): SagaGenerator( type: BootloaderResponseActionType, timeout = 500, -): SagaGenerator<{ - response?: T; - error?: BootloaderErrorResponseAction; - timeout?: boolean; -}> { - return yield* race({ +): SagaGenerator { + const { response, error, disconnected, timedOut } = yield* race({ response: take(type), error: take(BootloaderResponseActionType.Error), - timeout: delay(timeout), + disconnected: take(BootloaderConnectionActionType.DidDisconnect), + timedOut: delay(timeout), }); + + if (timedOut) { + yield* put(didFailToFinish(FailToFinishReasonType.TimedOut)); + yield* disconnectAndCancel(); + } + + if (error) { + yield* put( + didFailToFinish(FailToFinishReasonType.HubError, HubError.UnknownCommand), + ); + yield* disconnectAndCancel(); + } + + if (disconnected) { + yield* put(didFailToFinish(FailToFinishReasonType.Disconnected)); + yield* disconnectAndCancel(); + } + + defined(response); + + return response; } function* firmwareIterator(data: DataView, maxSize: number): Generator { @@ -108,7 +147,8 @@ function* firmwareIterator(data: DataView, maxSize: number): Generator { } /** - * Loads Pybricks firmware from a .zip file + * Loads Pybricks firmware from a .zip file. + * * @param data The zip file raw data * @param program User program or `undefined` to use main.py from firmware.zip */ @@ -121,11 +161,11 @@ function* loadFirmware( if (readerErr) { // istanbul ignore else: unexpected error if (readerErr instanceof FirmwareReaderError) { - yield* put(didFailToStart(FailToStartReasonType.ZipError, readerErr)); + yield* put(didFailToFinish(FailToFinishReasonType.ZipError, readerErr)); } else { - yield* put(didFailToStart(FailToStartReasonType.Unknown, readerErr)); + yield* put(didFailToFinish(FailToFinishReasonType.Unknown, readerErr)); } - yield* cancel(); + yield* disconnectAndCancel(); } defined(reader); @@ -139,9 +179,14 @@ function* loadFirmware( } if (metadata['mpy-abi-version'] !== 5) { - throw Error( - `Firmware requires mpy-cross ABI version ${metadata['mpy-abi-version']} we have v5`, + yield* put( + didFailToFinish( + FailToFinishReasonType.BadMetadata, + 'mpy-abi-version', + MetadataProblem.NotSupported, + ), ); + yield* disconnectAndCancel(); } yield* put(compile(program, metadata['mpy-cross-options'])); @@ -151,7 +196,8 @@ function* loadFirmware( }); if (mpyFail) { - throw Error(mpyFail.err.join('\n')); + yield* put(didFailToFinish(FailToFinishReasonType.FailedToCompile)); + yield* disconnectAndCancel(); } defined(mpy); @@ -164,7 +210,8 @@ function* loadFirmware( const firmwareView = new DataView(firmware.buffer); if (firmware.length > metadata['max-firmware-size']) { - throw Error('firmware + main.mpy is too large'); + yield* put(didFailToFinish(FailToFinishReasonType.FirmwareSize)); + yield* disconnectAndCancel(); } firmware.set(firmwareBase); @@ -172,15 +219,22 @@ function* loadFirmware( firmware.set(mpy.data, metadata['user-mpy-offset'] + 4); if (metadata['checksum-type'] !== 'sum') { - throw Error(`Unknown checksum type "${metadata['checksum-type']}"`); + yield* put( + didFailToFinish( + FailToFinishReasonType.BadMetadata, + 'checksum-type', + MetadataProblem.NotSupported, + ), + ); + yield* disconnectAndCancel(); } - firmwareView.setUint32( - checksumOffset, - sumComplement32(firmwareIterator(firmwareView, metadata['max-firmware-size'])), - true, + const checksum = sumComplement32( + firmwareIterator(firmwareView, metadata['max-firmware-size']), ); + firmwareView.setUint32(checksumOffset, checksum, true); + return { firmware, deviceId: metadata['device-id'] }; } @@ -189,183 +243,198 @@ function* loadFirmware( * @param action The action that triggered this saga. */ function* flashFirmware(action: FlashFirmwareFlashAction): Generator { - let firmware: Uint8Array | undefined = undefined; - let deviceId: HubType | undefined = undefined; + try { + let firmware: Uint8Array | undefined = undefined; + let deviceId: HubType | undefined = undefined; - let program: string | undefined = undefined; + let program: string | undefined = undefined; - const flashCurrentProgram = yield* select( - (s: RootState) => s.settings.flashCurrentProgram, - ); - - if (flashCurrentProgram) { - const editor = yield* select((s: RootState) => s.editor.current); - - // istanbul ignore if: it is a bug to dispatch this action with no current editor - if (editor === null) { - console.error('flashFirmware: No current editor'); - return; - } - - program = editor.getValue(); - } - - if (action.data !== undefined) { - ({ firmware, deviceId } = yield* loadFirmware(action.data, program)); - } - - yield* put(connect()); - const connectResult = yield* take< - | BootloaderConnectionDidConnectAction - | BootloaderConnectionDidFailToConnectAction - >([ - BootloaderConnectionActionType.DidConnect, - BootloaderConnectionActionType.DidFailToConnect, - ]); - - if (connectResult.type === BootloaderConnectionActionType.DidFailToConnect) { - return; - } - - const nextMessageId = yield* getContext<() => number>('nextMessageId'); - - const infoAction = yield* put(infoRequest(nextMessageId())); - const { info } = yield* all({ - sent: waitForDidRequest(infoAction.id), - info: waitForResponse( - BootloaderResponseActionType.Info, - ), - }); - if (!info.response) { - throw Error(`failed to get info: ${info}`); - } - - if (deviceId !== undefined && info.response.hubType !== deviceId) { - throw Error( - `Connected to ${info.response.hubType} but firmware is for ${deviceId}`, + const flashCurrentProgram = yield* select( + (s: RootState) => s.settings.flashCurrentProgram, ); - } - if (firmware === undefined) { - const firmwarePath = firmwareZipMap.get(info.response.hubType); - if (firmwarePath === undefined) { - yield* put( - notification.add( - 'error', - "Sorry, we don't have firmware for this hub yet.", - ), - ); - yield* put(disconnectRequest(nextMessageId())); + if (flashCurrentProgram) { + const editor = yield* select((s: RootState) => s.editor.current); + + // istanbul ignore if: it is a bug to dispatch this action with no current editor + if (editor === null) { + console.error('flashFirmware: No current editor'); + return; + } + + program = editor.getValue(); + } + + if (action.data !== undefined) { + ({ firmware, deviceId } = yield* loadFirmware(action.data, program)); + } + + yield* put(connect()); + const connectResult = yield* take([ + BootloaderConnectionActionType.DidConnect, + BootloaderConnectionActionType.DidFailToConnect, + ]); + + if (connectResult.type === BootloaderConnectionActionType.DidFailToConnect) { + yield* put(didFailToFinish(FailToFinishReasonType.FailedToConnect)); return; } - const response = yield* call(() => fetch(firmwarePath)); - if (!response.ok) { - yield* put(notification.add('error', 'Failed to fetch firmware.')); - const disconnectAction = yield* put(disconnectRequest(nextMessageId())); - yield* waitForDidRequest(disconnectAction.id); - return; - } + const nextMessageId = yield* getContext<() => number>('nextMessageId'); - const data = yield* call(() => response.arrayBuffer()); - ({ firmware, deviceId } = yield* loadFirmware(data, program)); - - if (deviceId !== undefined && info.response.hubType !== deviceId) { - throw Error( - `Connected to ${info.response.hubType} but firmware is for ${deviceId}`, - ); - } - } - - yield* put(didStart()); - - const eraseAction = yield* put(eraseRequest(nextMessageId())); - const { erase } = yield* all({ - sent: waitForDidRequest(eraseAction.id), - erase: waitForResponse( - BootloaderResponseActionType.Erase, - 5000, - ), - }); - if (!erase.response || erase.response.result) { - // TODO: proper error handling - throw Error(`Failed to erase: ${erase}`); - } - - const initAction = yield* put(initRequest(nextMessageId(), firmware.length)); - const { init } = yield* all({ - sent: waitForDidRequest(initAction.id), - init: waitForResponse( - BootloaderResponseActionType.Init, - ), - }); - if (!init.response || init.response.result) { - // TODO: proper error handling - throw Error(`Failed to init: ${init}`); - } - - // 14 is "safe" size for all hubs - const maxDataSize = MaxProgramFlashSize.get(info.response.hubType) || 14; - - for (let count = 1, offset = 0; ; count++) { - const payload = firmware.slice(offset, offset + maxDataSize); - const programAction = yield* put( - programRequest( - nextMessageId(), - info.response.startAddress + offset, - payload.buffer, + const infoAction = yield* put(infoRequest(nextMessageId())); + const { info } = yield* all({ + sent: waitForDidRequest(infoAction.id), + info: waitForResponse( + BootloaderResponseActionType.Info, ), - ); - yield* waitForDidRequest(programAction.id); + }); - yield* put(didProgress(offset / firmware.length)); - - // we don't want to request checksum if this is the last packet since - // the bootloader will send a response to the program request already. - offset += maxDataSize; - if (offset >= firmware.length) { - break; + if (deviceId !== undefined && info.hubType !== deviceId) { + yield* put(didFailToFinish(FailToFinishReasonType.DeviceMismatch)); + yield* disconnectAndCancel(); } - // Request checksum every 10 packets to prevent buffer overrun on - // the hub because of sending too much data at once. The actual - // number of packets that can be queued in the Bluetooth chip on - // the hub is not known and could vary by device. - if (count % 10 === 0) { - const checksumAction = yield* put(checksumRequest(nextMessageId())); - const { checksum } = yield* all({ - sent: waitForDidRequest(checksumAction.id), - checksum: waitForResponse( - BootloaderResponseActionType.Checksum, - 5000, - ), - }); - if (!checksum.response) { - // TODO: proper error handling - throw Error(`Failed to get checksum: ${checksum}`); + if (firmware === undefined) { + const firmwarePath = firmwareZipMap.get(info.hubType); + if (firmwarePath === undefined) { + yield* put(didFailToFinish(FailToFinishReasonType.NoFirmware)); + yield* disconnectAndCancel(); + } + + defined(firmwarePath); + + const response = yield* call(() => fetch(firmwarePath)); + if (!response.ok) { + yield* put( + didFailToFinish(FailToFinishReasonType.FailedToFetch, response), + ); + yield* disconnectAndCancel(); + } + + const data = yield* call(() => response.arrayBuffer()); + ({ firmware, deviceId } = yield* loadFirmware(data, program)); + + if (deviceId !== undefined && info.hubType !== deviceId) { + yield* put(didFailToFinish(FailToFinishReasonType.DeviceMismatch)); + yield* disconnectAndCancel(); } } + + yield* put(didStart()); + + const eraseAction = yield* put(eraseRequest(nextMessageId())); + const { erase } = yield* all({ + sent: waitForDidRequest(eraseAction.id), + erase: waitForResponse( + BootloaderResponseActionType.Erase, + 5000, + ), + }); + if (erase.result !== Result.OK) { + yield* put( + didFailToFinish(FailToFinishReasonType.HubError, HubError.EraseFailed), + ); + yield* disconnectAndCancel(); + } + + const initAction = yield* put(initRequest(nextMessageId(), firmware.length)); + const { init } = yield* all({ + sent: waitForDidRequest(initAction.id), + init: waitForResponse( + BootloaderResponseActionType.Init, + ), + }); + if (init.result) { + yield* put( + didFailToFinish(FailToFinishReasonType.HubError, HubError.InitFailed), + ); + yield* disconnectAndCancel(); + } + + // 14 is "safe" size for all hubs + const maxDataSize = MaxProgramFlashSize.get(info.hubType) || 14; + + for (let count = 1, offset = 0; ; count++) { + const payload = firmware.slice(offset, offset + maxDataSize); + const programAction = yield* put( + programRequest( + nextMessageId(), + info.startAddress + offset, + payload.buffer, + ), + ); + yield* waitForDidRequest(programAction.id); + + yield* put(didProgress(offset / firmware.length)); + + // we don't want to request checksum if this is the last packet since + // the bootloader will send a response to the program request already. + offset += maxDataSize; + if (offset >= firmware.length) { + break; + } + + // Request checksum every 10 packets to prevent buffer overrun on + // the hub because of sending too much data at once. The actual + // number of packets that can be queued in the Bluetooth chip on + // the hub is not known and could vary by device. + if (count % 10 === 0) { + const checksumAction = yield* put(checksumRequest(nextMessageId())); + yield* all({ + sent: waitForDidRequest(checksumAction.id), + checksum: waitForResponse( + BootloaderResponseActionType.Checksum, + 5000, + ), + }); + } + } + + const flash = yield* waitForResponse( + BootloaderResponseActionType.Program, + 5000, + ); + + if (flash.count !== firmware.length) { + yield* put( + didFailToFinish( + FailToFinishReasonType.HubError, + HubError.CountMismatch, + ), + ); + yield* disconnectAndCancel(); + } + + const checksum = firmware.reduce((prev, curr) => prev ^ curr, 0xff); + if (flash.checksum !== checksum) { + if (process.env.NODE_ENV !== 'test') { + console.log( + 'checksum:', + flash.checksum.toString(16).padStart(2, '0').padStart(4, '0x'), + checksum.toString(16).padStart(2, '0').padStart(4, '0x'), + ); + } + yield* put( + didFailToFinish( + FailToFinishReasonType.HubError, + HubError.ChecksumMismatch, + ), + ); + yield* disconnectAndCancel(); + } + + yield* put(didProgress(1)); + + // this will cause the remote device to disconnect and reboot + const rebootAction = yield* put(rebootRequest(nextMessageId())); + yield* waitForDidRequest(rebootAction.id); + + yield* put(didFinish()); + } catch (err) { + yield* put(didFailToFinish(FailToFinishReasonType.Unknown, err)); + yield* disconnectAndCancel(); } - - const flash = yield* waitForResponse( - BootloaderResponseActionType.Program, - 5000, - ); - if (!flash.response) { - throw Error(`failed to get final response: ${flash}`); - } - if (flash.response.count !== firmware.length) { - // TODO: proper error handling - throw Error("Didn't flash all bytes"); - } - - yield* put(didProgress(1)); - - // this will cause the remote device to disconnect and reboot - const rebootAction = yield* put(rebootRequest(nextMessageId())); - yield* waitForDidRequest(rebootAction.id); - - yield* put(didFinish()); } export default function* (): Generator { diff --git a/src/sagas/hub.test.ts b/src/sagas/hub.test.ts index 9af02713..150b2054 100644 --- a/src/sagas/hub.test.ts +++ b/src/sagas/hub.test.ts @@ -22,10 +22,12 @@ jest.mock('ace-builds'); describe('downloadAndRun', () => { test('no errors', async () => { - const saga = new AsyncSaga(hub, { nextMessageId: createCountFunc() }); - const mockEditor = mock(); - saga.setState({ editor: { current: mockEditor } }); + const saga = new AsyncSaga( + hub, + { editor: { current: mockEditor } }, + { nextMessageId: createCountFunc() }, + ); saga.put(downloadAndRun()); @@ -76,7 +78,7 @@ describe('downloadAndRun', () => { }); test('repl', async () => { - const saga = new AsyncSaga(hub, { nextMessageId: createCountFunc() }); + const saga = new AsyncSaga(hub, {}, { nextMessageId: createCountFunc() }); saga.put(repl()); @@ -87,7 +89,7 @@ test('repl', async () => { }); test('stop', async () => { - const saga = new AsyncSaga(hub, { nextMessageId: createCountFunc() }); + const saga = new AsyncSaga(hub, {}, { nextMessageId: createCountFunc() }); saga.put(stop()); diff --git a/src/sagas/license.test.ts b/src/sagas/license.test.ts index 57d993cc..95daafb1 100644 --- a/src/sagas/license.test.ts +++ b/src/sagas/license.test.ts @@ -16,7 +16,7 @@ afterAll(() => { describe('fetchLicenses', () => { test('first call', async () => { const testLicenseList: LicenseList = []; - const saga = new AsyncSaga(license); + const saga = new AsyncSaga(license, { license: { list: null } }); jest.spyOn(globalThis, 'fetch').mockResolvedValue( new Response(JSON.stringify(testLicenseList)), @@ -24,7 +24,6 @@ describe('fetchLicenses', () => { // initially, license list starts as null, so fetch is called to get // the list - saga.setState({ license: { list: null } }); saga.put(openLicenseDialog()); const action = await saga.take(); @@ -34,7 +33,7 @@ describe('fetchLicenses', () => { }); test('second call', async () => { const testLicenseList: LicenseList = []; - const saga = new AsyncSaga(license); + const saga = new AsyncSaga(license, { license: { list: testLicenseList } }); jest.spyOn(globalThis, 'fetch').mockRejectedValue( 'fetch () should not have been called', @@ -42,7 +41,6 @@ describe('fetchLicenses', () => { // after we have the list, we don't fetch it again since it will // always be the same list - saga.setState({ license: { list: testLicenseList } }); saga.put(openLicenseDialog()); // have to yield to be sure fetch call would have taken place on error @@ -52,11 +50,10 @@ describe('fetchLicenses', () => { }); test('failed fetch', async () => { const failResponse = new Response(undefined, { status: 404 }); - const saga = new AsyncSaga(license); + const saga = new AsyncSaga(license, { license: { list: null } }); jest.spyOn(globalThis, 'fetch').mockResolvedValue(failResponse); - saga.setState({ license: { list: null } }); saga.put(openLicenseDialog()); const action = await saga.take(); diff --git a/src/sagas/lwp3-bootloader-ble.ts b/src/sagas/lwp3-bootloader-ble.ts index 946b45d3..8fe9fbc7 100644 --- a/src/sagas/lwp3-bootloader-ble.ts +++ b/src/sagas/lwp3-bootloader-ble.ts @@ -1,10 +1,10 @@ // SPDX-License-Identifier: MIT -// Copyright (c) 2020 The Pybricks Authors +// Copyright (c) 2020-2021 The Pybricks Authors // File: sagas/lwp3-bootloader-ble.ts // Handles Bluetooth Low Energy connection to LEGO Wireless Protocol v3 Bootloader service. import { END, eventChannel } from 'redux-saga'; -import { call, cancel, put, takeEvery, takeMaybe } from 'redux-saga/effects'; +import { call, cancel, put, spawn, takeEvery, takeMaybe } from 'redux-saga/effects'; import { BootloaderConnectionAction, BootloaderConnectionActionType, @@ -132,6 +132,14 @@ function* connect(_action: BootloaderConnectionAction): Generator { }); try { + try { + yield call([characteristic, 'stopNotifications']); + } catch { + // HACK: Chromium on Linux (BlueZ) will not receive notifications + // if a device disconnects while notifications are enabled and then + // reconnects. So we have to call stopNotifications() first to get + // back to a known state. https://crbug.com/1170085 + } yield call([characteristic, 'startNotifications']); } catch (err) { notificationChannel.close(); @@ -141,8 +149,19 @@ function* connect(_action: BootloaderConnectionAction): Generator { return; } + // Spawning write so that it can't be canceled. This is important because + // other sagas always expect it to complete with success action or error + // action. + function* spawnWrite(action: BootloaderConnectionSendAction): Generator { + yield spawn(write, characteristic, action); + } + yield takeEvery(notificationChannel, handleNotify); - yield takeEvery(BootloaderConnectionActionType.Send, write, characteristic); + yield takeEvery(BootloaderConnectionActionType.Send, spawnWrite); + yield takeEvery( + BootloaderConnectionActionType.Disconnect, + server.disconnect.bind(server), + ); yield put(didConnect()); diff --git a/src/sagas/mpy.test.ts b/src/sagas/mpy.test.ts index 141a0f62..1ca2232d 100644 --- a/src/sagas/mpy.test.ts +++ b/src/sagas/mpy.test.ts @@ -37,7 +37,13 @@ test('compiler error works', async () => { const action = await saga.take(); expect(action.type).toBe(MpyActionType.DidFailToCompile); const { err } = action as MpyDidFailToCompileAction; - expect(err).toMatchSnapshot(); + expect(err).toMatchInlineSnapshot(` + Array [ + "Traceback (most recent call last):", + " File \\"main.py\\", line 1", + "SyntaxError: invalid syntax", + ] + `); await saga.end(); }); diff --git a/src/sagas/notification.test.ts b/src/sagas/notification.test.ts index 9b529eb9..b7b114bc 100644 --- a/src/sagas/notification.test.ts +++ b/src/sagas/notification.test.ts @@ -2,6 +2,7 @@ // Copyright (c) 2021 The Pybricks Authors import { IToaster } from '@blueprintjs/core'; +import { FirmwareReaderError, FirmwareReaderErrorCode } from '@pybricks/firmware'; import { AsyncSaga } from '../../test'; import { Action } from '../actions'; import { @@ -9,6 +10,12 @@ import { didFailToConnect as bleDidFailToConnect, } from '../actions/ble'; import { storageChanged } from '../actions/editor'; +import { + FailToFinishReasonType, + HubError, + MetadataProblem, + didFailToFinish, +} from '../actions/flash-firmware'; import { BootloaderConnectionFailureReason, didFailToConnect as bootloaderDidFailToConnect, @@ -37,6 +44,31 @@ test.each([ add('warning', 'message'), add('error', 'message', 'url'), didUpdate({} as ServiceWorkerRegistration), + didFailToFinish(FailToFinishReasonType.TimedOut), + didFailToFinish( + FailToFinishReasonType.BleError, + new DOMException('test error', 'NetworkError'), + ), + didFailToFinish(FailToFinishReasonType.Disconnected), + didFailToFinish(FailToFinishReasonType.HubError, HubError.UnknownCommand), + didFailToFinish(FailToFinishReasonType.NoFirmware), + didFailToFinish(FailToFinishReasonType.DeviceMismatch), + didFailToFinish( + FailToFinishReasonType.FailedToFetch, + new Response(undefined, { status: 404 }), + ), + didFailToFinish( + FailToFinishReasonType.ZipError, + new FirmwareReaderError(FirmwareReaderErrorCode.ZipError), + ), + didFailToFinish( + FailToFinishReasonType.BadMetadata, + 'device-id', + MetadataProblem.NotSupported, + ), + didFailToFinish(FailToFinishReasonType.FailedToCompile), + didFailToFinish(FailToFinishReasonType.FirmwareSize), + didFailToFinish(FailToFinishReasonType.Unknown, new Error('test error')), ])('actions that should show notification: %o', async (action: Action) => { const getToasts = jest.fn().mockReturnValue([]); const show = jest.fn(); @@ -50,7 +82,7 @@ test.each([ clear, }; - const saga = new AsyncSaga(notification, { notification: { toaster } }); + const saga = new AsyncSaga(notification, {}, { notification: { toaster } }); saga.put(action); @@ -64,6 +96,7 @@ test.each([ test.each([ bleDidFailToConnect({ reason: BleDeviceFailToConnectReasonType.Canceled }), bootloaderDidFailToConnect(BootloaderConnectionFailureReason.Canceled), + didFailToFinish(FailToFinishReasonType.FailedToConnect), didSucceed({} as ServiceWorkerRegistration), ])('actions that should not show a notification: %o', async (action: Action) => { const getToasts = jest.fn().mockReturnValue([]); @@ -78,7 +111,7 @@ test.each([ clear, }; - const saga = new AsyncSaga(notification, { notification: { toaster } }); + const saga = new AsyncSaga(notification, {}, { notification: { toaster } }); saga.put(action); @@ -105,7 +138,7 @@ test.each([[didCompile(new Uint8Array()), MessageId.MpyError]])( clear, }; - const saga = new AsyncSaga(notification, { notification: { toaster } }); + const saga = new AsyncSaga(notification, {}, { notification: { toaster } }); saga.put(action); diff --git a/src/sagas/notification.ts b/src/sagas/notification.ts index 687e0e59..7f2aa445 100644 --- a/src/sagas/notification.ts +++ b/src/sagas/notification.ts @@ -21,6 +21,11 @@ import { BleDeviceFailToConnectReasonType, } from '../actions/ble'; import { EditorActionType, reloadProgram } from '../actions/editor'; +import { + FailToFinishReasonType, + FlashFirmwareActionType, + FlashFirmwareDidFailToFinishAction, +} from '../actions/flash-firmware'; import { BootloaderConnectionActionType, BootloaderConnectionDidFailToConnectAction, @@ -226,6 +231,66 @@ function* showEditorStorageChanged(): Generator { yield put(reloadProgram()); } +function* showFlashFirmwareError( + action: FlashFirmwareDidFailToFinishAction, +): Generator { + switch (action.reason.reason) { + case FailToFinishReasonType.TimedOut: + yield* showSingleton(Level.Error, MessageId.FlashFirmwareTimedOut); + break; + case FailToFinishReasonType.BleError: + yield* showUnexpectedError( + MessageId.FlashFirmwareBleError, + action.reason.err, + ); + break; + case FailToFinishReasonType.Disconnected: + yield* showSingleton(Level.Error, MessageId.FlashFirmwareDisconnected); + break; + case FailToFinishReasonType.HubError: + yield* showSingleton(Level.Error, MessageId.FlashFirmwareHubError); + if (process.env.NODE_ENV !== 'test') { + console.error(action.reason.hubError); + } + break; + case FailToFinishReasonType.NoFirmware: + yield* showSingleton(Level.Error, MessageId.FlashFirmwareUnsupportedDevice); + break; + case FailToFinishReasonType.DeviceMismatch: + yield* showSingleton(Level.Error, MessageId.FlashFirmwareDeviceMismatch); + break; + case FailToFinishReasonType.FailedToFetch: + yield* showSingleton(Level.Error, MessageId.FlashFirmwareFailToFetch, { + status: action.reason.response.statusText, + }); + break; + case FailToFinishReasonType.ZipError: + yield* showSingleton(Level.Error, MessageId.FlashFirmwareBadZipFile); + if (process.env.NODE_ENV !== 'test') { + console.error(action.reason.err); + } + break; + case FailToFinishReasonType.BadMetadata: + yield* showSingleton(Level.Error, MessageId.FlashFirmwareBadMetadata); + if (process.env.NODE_ENV !== 'test') { + console.error(action.reason.property, action.reason.problem); + } + break; + case FailToFinishReasonType.FailedToCompile: + yield* showSingleton(Level.Error, MessageId.FlashFirmwareCompileError); + break; + case FailToFinishReasonType.FirmwareSize: + yield* showSingleton(Level.Error, MessageId.FlashFirmwareSizeTooBig); + break; + case FailToFinishReasonType.Unknown: + yield* showUnexpectedError( + MessageId.FlashFirmwareUnexpectedError, + action.reason.err, + ); + break; + } +} + function* dismissCompilerError(): Generator { const { toaster } = (yield getContext('notification')) as NotificationContext; toaster.dismiss(MessageId.MpyError); @@ -282,6 +347,7 @@ export default function* (): Generator { showBootloaderDidFailToConnectError, ); yield takeEvery(EditorActionType.StorageChanged, showEditorStorageChanged); + yield takeEvery(FlashFirmwareActionType.DidFailToFinish, showFlashFirmwareError); yield takeEvery(MpyActionType.DidCompile, dismissCompilerError); yield takeEvery(MpyActionType.DidFailToCompile, showCompilerError); yield takeEvery(NotificationActionType.Add, addNotification); diff --git a/src/sagas/settings.test.ts b/src/sagas/settings.test.ts index 8e14d15b..da9dea7c 100644 --- a/src/sagas/settings.test.ts +++ b/src/sagas/settings.test.ts @@ -210,7 +210,7 @@ describe('startup', () => { describe('store settings to local storage', () => { test('failed storage', async () => { - const saga = new AsyncSaga(settings); + const saga = new AsyncSaga(settings, { settings: { showDocs: false } }); const testError = new Error('local storage is disabled'); @@ -220,7 +220,6 @@ describe('store settings to local storage', () => { throw testError; }); - saga.setState({ settings: { showDocs: false } }); saga.put(setBoolean(SettingId.ShowDocs, true)); expect(mockSetItem).toHaveBeenCalled(); @@ -236,7 +235,7 @@ describe('store settings to local storage', () => { }); test('showDocs', async () => { - const saga = new AsyncSaga(settings); + const saga = new AsyncSaga(settings, { settings: { showDocs: false } }); const mockSetItem = jest .spyOn(Object.getPrototypeOf(window.localStorage), 'setItem') @@ -245,7 +244,6 @@ describe('store settings to local storage', () => { expect(value).toBe('true'); }); - saga.setState({ settings: { showDocs: false } }); saga.put(setBoolean(SettingId.ShowDocs, true)); expect(mockSetItem).toHaveBeenCalled(); @@ -256,7 +254,7 @@ describe('store settings to local storage', () => { }); test('darkMode', async () => { - const saga = new AsyncSaga(settings); + const saga = new AsyncSaga(settings, { settings: { darkMode: true } }); const mockSetItem = jest .spyOn(Object.getPrototypeOf(window.localStorage), 'setItem') @@ -265,7 +263,6 @@ describe('store settings to local storage', () => { expect(value).toBe('false'); }); - saga.setState({ settings: { darkMode: true } }); saga.put(setBoolean(SettingId.DarkMode, false)); expect(mockSetItem).toHaveBeenCalled(); @@ -276,7 +273,9 @@ describe('store settings to local storage', () => { }); test('flashCurrentProgram', async () => { - const saga = new AsyncSaga(settings); + const saga = new AsyncSaga(settings, { + settings: { flashCurrentProgram: true }, + }); const mockSetItem = jest .spyOn(Object.getPrototypeOf(window.localStorage), 'setItem') @@ -285,7 +284,6 @@ describe('store settings to local storage', () => { expect(value).toBe('false'); }); - saga.setState({ settings: { flashCurrentProgram: true } }); saga.put(setBoolean(SettingId.FlashCurrentProgram, false)); expect(mockSetItem).toHaveBeenCalled(); diff --git a/src/sagas/terminal.test.ts b/src/sagas/terminal.test.ts index abefe1d2..b1fbbaa2 100644 --- a/src/sagas/terminal.test.ts +++ b/src/sagas/terminal.test.ts @@ -30,10 +30,13 @@ import terminal from './terminal'; describe('Data receiver filters out hub status', () => { test('normal message - no status', async () => { - const saga = new AsyncSaga(terminal, { nextMessageId: createCountFunc() }); + const saga = new AsyncSaga( + terminal, + { hub: { runtime: HubRuntimeState.Unknown } }, + { nextMessageId: createCountFunc() }, + ); // sending ASCII space character - saga.setState({ hub: { runtime: HubRuntimeState.Unknown } }); saga.put(notify(new DataView(new Uint8Array([0x20]).buffer))); const action = await saga.take(); @@ -44,9 +47,12 @@ describe('Data receiver filters out hub status', () => { }); test('checksum message', async () => { - const saga = new AsyncSaga(terminal, { nextMessageId: createCountFunc() }); + const saga = new AsyncSaga( + terminal, + { hub: { runtime: HubRuntimeState.Loading } }, + { nextMessageId: createCountFunc() }, + ); - saga.setState({ hub: { runtime: HubRuntimeState.Loading } }); saga.put(notify(new DataView(new Uint8Array([0xaa]).buffer))); const action = await saga.take(); @@ -57,10 +63,13 @@ describe('Data receiver filters out hub status', () => { }); test('idle message', async () => { - const saga = new AsyncSaga(terminal, { nextMessageId: createCountFunc() }); + const saga = new AsyncSaga( + terminal, + { hub: { runtime: HubRuntimeState.Unknown } }, + { nextMessageId: createCountFunc() }, + ); // '>>>> IDLE' - saga.setState({ hub: { runtime: HubRuntimeState.Unknown } }); saga.put( notify( new DataView( @@ -89,10 +98,13 @@ describe('Data receiver filters out hub status', () => { }); test('idle message with extra text', async () => { - const saga = new AsyncSaga(terminal, { nextMessageId: createCountFunc() }); + const saga = new AsyncSaga( + terminal, + { hub: { runtime: HubRuntimeState.Unknown } }, + { nextMessageId: createCountFunc() }, + ); // '0>>>> IDLE1' - saga.setState({ hub: { runtime: HubRuntimeState.Unknown } }); saga.put( notify( new DataView( @@ -133,10 +145,13 @@ describe('Data receiver filters out hub status', () => { }); test('error message', async () => { - const saga = new AsyncSaga(terminal, { nextMessageId: createCountFunc() }); + const saga = new AsyncSaga( + terminal, + { hub: { runtime: HubRuntimeState.Unknown } }, + { nextMessageId: createCountFunc() }, + ); // '>>>> ERROR' - saga.setState({ hub: { runtime: HubRuntimeState.Unknown } }); saga.put( notify( new DataView( @@ -166,10 +181,13 @@ describe('Data receiver filters out hub status', () => { }); test('error message with extra text', async () => { - const saga = new AsyncSaga(terminal, { nextMessageId: createCountFunc() }); + const saga = new AsyncSaga( + terminal, + { hub: { runtime: HubRuntimeState.Unknown } }, + { nextMessageId: createCountFunc() }, + ); // '0>>>> ERROR1' - saga.setState({ hub: { runtime: HubRuntimeState.Unknown } }); saga.put( notify( new DataView( @@ -211,10 +229,13 @@ describe('Data receiver filters out hub status', () => { }); test('running message', async () => { - const saga = new AsyncSaga(terminal, { nextMessageId: createCountFunc() }); + const saga = new AsyncSaga( + terminal, + { hub: { runtime: HubRuntimeState.Unknown } }, + { nextMessageId: createCountFunc() }, + ); // '>>>> ERROR' - saga.setState({ hub: { runtime: HubRuntimeState.Unknown } }); saga.put( notify( new DataView( @@ -246,10 +267,13 @@ describe('Data receiver filters out hub status', () => { }); test('running message with extra text', async () => { - const saga = new AsyncSaga(terminal, { nextMessageId: createCountFunc() }); + const saga = new AsyncSaga( + terminal, + { hub: { runtime: HubRuntimeState.Unknown } }, + { nextMessageId: createCountFunc() }, + ); // '0>>>> RUNNING1' - saga.setState({ hub: { runtime: HubRuntimeState.Unknown } }); saga.put( notify( new DataView( @@ -294,7 +318,7 @@ describe('Data receiver filters out hub status', () => { }); test('Terminal data source responds to send data actions', async () => { - const saga = new AsyncSaga(terminal, { nextMessageId: createCountFunc() }); + const saga = new AsyncSaga(terminal, {}, { nextMessageId: createCountFunc() }); saga.put(didStart()); const dataSourceAction = await saga.take(); @@ -321,7 +345,7 @@ describe('Terminal data source responds to receive data actions', () => { const expected = new Uint8Array([0x74, 0x65, 0x73, 0x74, 0x31, 0x32, 0x33, 0x34]); test('basic function works', async () => { - const saga = new AsyncSaga(terminal, { nextMessageId: createCountFunc() }); + const saga = new AsyncSaga(terminal, {}, { nextMessageId: createCountFunc() }); saga.put(receiveData('test1234')); @@ -333,7 +357,7 @@ describe('Terminal data source responds to receive data actions', () => { }); test('messages are queued until previous has completed', async () => { - const saga = new AsyncSaga(terminal, { nextMessageId: createCountFunc() }); + const saga = new AsyncSaga(terminal, {}, { nextMessageId: createCountFunc() }); saga.put(receiveData('test1234')); await delay(50); // without delay, messages are combined @@ -361,7 +385,7 @@ describe('Terminal data source responds to receive data actions', () => { }); test('messages are queued until previous has failed', async () => { - const saga = new AsyncSaga(terminal, { nextMessageId: createCountFunc() }); + const saga = new AsyncSaga(terminal, {}, { nextMessageId: createCountFunc() }); saga.put(receiveData('test1234')); await delay(50); // without delay, messages are combined @@ -391,7 +415,7 @@ describe('Terminal data source responds to receive data actions', () => { }); test('small messages are combined', async () => { - const saga = new AsyncSaga(terminal, { nextMessageId: createCountFunc() }); + const saga = new AsyncSaga(terminal, {}, { nextMessageId: createCountFunc() }); saga.put(receiveData('test1234')); saga.put(receiveData('test1234')); @@ -406,7 +430,7 @@ describe('Terminal data source responds to receive data actions', () => { }); test('long messages are split', async () => { - const saga = new AsyncSaga(terminal, { nextMessageId: createCountFunc() }); + const saga = new AsyncSaga(terminal, {}, { nextMessageId: createCountFunc() }); saga.put(receiveData('012345678901234567890123456789')); diff --git a/test/index.ts b/test/index.ts index b1833718..6451e979 100644 --- a/test/index.ts +++ b/test/index.ts @@ -16,11 +16,15 @@ export class AsyncSaga { private state: RecursivePartial; private task: Task; - public constructor(saga: Saga, context?: Record) { + public constructor( + saga: Saga, + state: RecursivePartial = {}, + context?: Record, + ) { this.channel = stdChannel(); this.dispatches = []; this.takers = []; - this.state = {}; + this.state = state; this.task = runSaga( { channel: this.channel, @@ -67,8 +71,11 @@ export class AsyncSaga { return Promise.resolve(next); } - public setState(state: RecursivePartial): void { - this.state = state; + public updateState(state: RecursivePartial): void { + for (const key of Object.keys(state) as Array) { + // @ts-expect-error: writing to readonly for testing + this.state[key] = { ...this.state[key], ...state[key] }; + } } public async end(): Promise {