Skip to content

Commit 2eca252

Browse files
shaosterdelucis
andauthored
feat: Improve client error handling [Part 1] (#940)
* feature(#723): Improve client error handling [Part 1] This PR sets up the initial reducer/master plumbing for error handling by introducing a "transients" option bag containing errors. The goal of this change is to be as backwards-compatible as possible, with subsequent changes building on top of the API extensions added here. Notes: - Previously, there was an assumption that the state resulting from a reducer processing an action is suitable for client consumption. - Now, transient artifacts might be appended onto the state and need to be stripped before being sent to the client. - To work around this change, I added some new types to help signal the "transient" nature of State that enters and exits the reducer. - I'm assuming the master is the single caller of the core reducer and leaving it up to the master to do this stripping. * XXXSQUASHXXX: Iterate on PR feedback (3). - Fix typo in error codes - Fix typo in optional type. - Strip transients early in the reducer, obviating type changes to the plugins/main code. - Unify the re-dispatch-on-transient pattern between master and client with a new middleware. - Fix some fragile tests (See #941). - Narrow client/master test changes for new functionality. Left some TODO/dev notes for things to be done in subsequent PRs for #723 and for hypothetical future discussion. - Remove currently unused ActionResult to avoid extraneous diffs. * Expand union type to include undefined Co-authored-by: Chris Swithinbank <[email protected]> * Update error types for action_invalid. Co-authored-by: Chris Swithinbank <[email protected]>
1 parent 4163e09 commit 2eca252

11 files changed

Lines changed: 415 additions & 74 deletions

File tree

src/client/client.test.ts

Lines changed: 71 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
* https://opensource.org/licenses/MIT.
77
*/
88

9+
import { INVALID_MOVE } from '../core/constants';
910
import { createStore } from 'redux';
1011
import { CreateGameReducer } from '../core/reducer';
1112
import { InitializeGame } from '../core/initialize';
@@ -498,6 +499,45 @@ describe('move dispatchers', () => {
498499
});
499500
});
500501

502+
describe('transient handling', () => {
503+
let client = null;
504+
505+
beforeEach(() => {
506+
client = Client({
507+
game: {
508+
moves: {
509+
A: () => ({}),
510+
Invalid: () => INVALID_MOVE,
511+
},
512+
},
513+
});
514+
});
515+
516+
test('regular move', () => {
517+
const result = client.moves.A();
518+
// TODO(#723): Check against a successful ActionResult.
519+
expect(result).toBeUndefined();
520+
const state = client.store.getState();
521+
// Slightly paranoid check to ensure we don't erroneously add transients.
522+
expect(state).toEqual(
523+
expect.not.objectContaining({ transients: expect.anything() })
524+
);
525+
});
526+
527+
test('invalid move', () => {
528+
const result = client.moves.Invalid();
529+
// TODO(#723): Check against an errored ActionResult.
530+
expect(result).toBeUndefined();
531+
const state = client.store.getState();
532+
// Ensure we've stripped the transients automagically.
533+
// At the time this test was written, this effectively ensures that Client
534+
// hooks up the TransientHandlingMiddleware correctly.
535+
expect(state).toEqual(
536+
expect.not.objectContaining({ transients: expect.anything() })
537+
);
538+
});
539+
});
540+
501541
describe('log handling', () => {
502542
let client = null;
503543

@@ -769,52 +809,62 @@ test('override game state', () => {
769809
expect(client.getState().G).toEqual({ moved: true });
770810
});
771811

812+
// TODO(#941): These tests should validate DOM mounting/unmounting.
772813
describe('start / stop', () => {
814+
beforeEach(() => {
815+
// Don't let other calls to `error` pollute this state.
816+
jest.resetAllMocks();
817+
});
818+
773819
test('mount on custom element', () => {
774820
const el = document.createElement('div');
775821
const client = Client({ game: {}, debug: { target: el } });
776-
client.start();
777-
client.stop();
778-
expect(error).toHaveBeenCalledTimes(3);
779-
expect(error).toHaveBeenCalledWith('disallowed move: B');
780-
expect(error).toHaveBeenCalledWith('disallowed move: C');
781-
expect(error).toHaveBeenLastCalledWith('disallowed move: A');
822+
expect(() => {
823+
client.start();
824+
client.stop();
825+
}).not.toThrow();
826+
expect(error).not.toHaveBeenCalled();
782827
});
783828

784829
test('no error when mounting on null element', () => {
785830
const client = Client({ game: {}, debug: { target: null } }) as any;
831+
expect(() => {
832+
client.start();
833+
client.stop();
834+
}).not.toThrow();
835+
786836
client.start();
787837
client.stop();
788838
expect(client.manager.debugPanel).toBe(null);
789839
});
790840

791841
test('override debug implementation', () => {
792842
const client = Client({ game: {}, debug: { impl: Debug } });
843+
expect(() => {
844+
client.start();
845+
client.stop();
846+
}).not.toThrow();
847+
793848
client.start();
794849
client.stop();
795-
expect(error).toHaveBeenCalledTimes(3);
796-
expect(error).toHaveBeenCalledWith('disallowed move: B');
797-
expect(error).toHaveBeenCalledWith('disallowed move: C');
798-
expect(error).toHaveBeenLastCalledWith('disallowed move: A');
850+
expect(error).not.toHaveBeenCalled();
799851
});
800852

801853
test('production mode', () => {
802854
process.env.NODE_ENV = 'production';
803855
const client = Client({ game: {} });
804-
client.start();
805-
client.stop();
806-
expect(error).toHaveBeenCalledTimes(3);
807-
expect(error).toHaveBeenCalledWith('disallowed move: B');
808-
expect(error).toHaveBeenCalledWith('disallowed move: C');
809-
expect(error).toHaveBeenLastCalledWith('disallowed move: A');
856+
expect(() => {
857+
client.start();
858+
client.stop();
859+
}).not.toThrow();
860+
expect(error).not.toHaveBeenCalled();
810861
});
811862

812863
test('try to stop without starting', () => {
813864
const client = Client({ game: {} });
814-
client.stop();
815-
expect(error).toHaveBeenCalledTimes(3);
816-
expect(error).toHaveBeenCalledWith('disallowed move: B');
817-
expect(error).toHaveBeenCalledWith('disallowed move: C');
818-
expect(error).toHaveBeenLastCalledWith('disallowed move: A');
865+
expect(() => {
866+
client.stop();
867+
}).not.toThrow();
868+
expect(error).not.toHaveBeenCalled();
819869
});
820870
});

src/client/client.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,10 @@ import * as Actions from '../core/action-types';
1414
import * as ActionCreators from '../core/action-creators';
1515
import { ProcessGameConfig } from '../core/game';
1616
import type Debug from './debug/Debug.svelte';
17-
import { CreateGameReducer } from '../core/reducer';
17+
import {
18+
CreateGameReducer,
19+
TransientHandlingMiddleware,
20+
} from '../core/reducer';
1821
import { InitializeGame } from '../core/initialize';
1922
import { PlayerView } from '../plugins/main';
2023
import type { Transport, TransportOpts } from './transport/transport';
@@ -303,6 +306,7 @@ export class _ClientImpl<G extends any = any> {
303306
};
304307

305308
const middleware = applyMiddleware(
309+
TransientHandlingMiddleware,
306310
SubscriptionMiddleware,
307311
TransportMiddleware,
308312
LogMiddleware

src/core/action-creators.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -149,3 +149,11 @@ export const plugin = (
149149
type: Actions.PLUGIN as typeof Actions.PLUGIN,
150150
payload: { type, args, playerID, credentials },
151151
});
152+
153+
/**
154+
* Private action used to strip transient metadata (e.g. errors) from the game
155+
* state.
156+
*/
157+
export const stripTransients = () => ({
158+
type: Actions.STRIP_TRANSIENTS as typeof Actions.STRIP_TRANSIENTS,
159+
});

src/core/action-types.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,3 +15,4 @@ export const UNDO = 'UNDO';
1515
export const UPDATE = 'UPDATE';
1616
export const PATCH = 'PATCH';
1717
export const PLUGIN = 'PLUGIN';
18+
export const STRIP_TRANSIENTS = 'STRIP_TRANSIENTS';

src/core/errors.ts

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
/*
2+
* Copyright 2017 The boardgame.io Authors
3+
*
4+
* Use of this source code is governed by a MIT-style
5+
* license that can be found in the LICENSE file or at
6+
* https://opensource.org/licenses/MIT.
7+
*/
8+
9+
export enum UpdateErrorType {
10+
// The action’s credentials were missing or invalid
11+
UnauthorizedAction = 'update/unauthorized_action',
12+
// The action’s matchID was not found
13+
MatchNotFound = 'update/match_not_found',
14+
// Could not apply Patch operation (rfc6902).
15+
PatchFailed = 'update/patch_failed',
16+
}
17+
18+
export enum ActionErrorType {
19+
// The action contained a stale state ID
20+
StaleStateId = 'action/stale_state_id',
21+
// The requested move is unknown or not currently available
22+
UnavailableMove = 'action/unavailable_move',
23+
// The move declared it was invalid (INVALID_MOVE constant)
24+
InvalidMove = 'action/invalid_move',
25+
// The player making the action is not currently active
26+
InactivePlayer = 'action/inactive_player',
27+
// The game has finished
28+
GameOver = 'action/gameover',
29+
// The requested action is disabled (e.g. undo/redo, events)
30+
ActionDisabled = 'action/action_disabled',
31+
// The requested action is not currently possible
32+
ActionInvalid = 'action/action_invalid',
33+
}

src/core/reducer.test.ts

Lines changed: 75 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,8 @@
77
*/
88

99
import { INVALID_MOVE } from './constants';
10-
import { CreateGameReducer } from './reducer';
10+
import { applyMiddleware, createStore } from 'redux';
11+
import { CreateGameReducer, TransientHandlingMiddleware } from './reducer';
1112
import { InitializeGame } from './initialize';
1213
import {
1314
makeMove,
@@ -32,6 +33,7 @@ const game: Game = {
3233
A: (G) => G,
3334
B: () => ({ moved: true }),
3435
C: () => ({ victory: true }),
36+
Invalid: () => INVALID_MOVE,
3537
},
3638
endIf: (G, ctx) => (G.victory ? ctx.currentPlayer : undefined),
3739
};
@@ -131,11 +133,16 @@ test('valid patch', () => {
131133

132134
test('invalid patch', () => {
133135
const originalState = { _stateID: 0, G: 'patch' } as State;
134-
const state = reducer(
136+
const { transients, ...state } = reducer(
135137
originalState,
136138
patch(0, 1, [{ op: 'replace', path: '/_stateIDD', value: 1 }], [])
137139
);
138140
expect(state).toEqual(originalState);
141+
expect(transients.error.type).toEqual('update/patch_failed');
142+
// It's an array.
143+
expect(transients.error.payload.length).toEqual(1);
144+
// It looks like the standard rfc6902 error language.
145+
expect(transients.error.payload[0].toString()).toContain('/_stateIDD');
139146
});
140147

141148
test('reset', () => {
@@ -431,7 +438,15 @@ describe('undo / redo', () => {
431438

432439
test('redo only resets deltalog if nothing to redo', () => {
433440
const state = reducer(initialState, makeMove('move', 'A', '0'));
434-
expect(reducer(state, redo())).toEqual({ ...state, deltalog: [] });
441+
expect(reducer(state, redo())).toMatchObject({
442+
...state,
443+
deltalog: [],
444+
transients: {
445+
error: {
446+
type: 'action/action_invalid',
447+
},
448+
},
449+
});
435450
});
436451
});
437452

@@ -526,13 +541,29 @@ describe('undo stack', () => {
526541

527542
test('can’t undo at the start of a turn', () => {
528543
const newState = reducer(state, undo());
529-
expect(newState).toEqual({ ...state, deltalog: [] });
544+
expect(newState).toMatchObject({
545+
...state,
546+
deltalog: [],
547+
transients: {
548+
error: {
549+
type: 'action/action_invalid',
550+
},
551+
},
552+
});
530553
});
531554

532555
test('can’t undo another player’s move', () => {
533556
state = reducer(state, makeMove('basic', null, '1'));
534557
const newState = reducer(state, undo('0'));
535-
expect(newState).toEqual({ ...state, deltalog: [] });
558+
expect(newState).toMatchObject({
559+
...state,
560+
deltalog: [],
561+
transients: {
562+
error: {
563+
type: 'action/action_invalid',
564+
},
565+
},
566+
});
536567
});
537568
});
538569

@@ -588,7 +619,15 @@ describe('redo stack', () => {
588619
expect(state._redo).toHaveLength(1);
589620
const newState = reducer(state, redo('0'));
590621
expect(state._redo).toHaveLength(1);
591-
expect(newState).toEqual({ ...state, deltalog: [] });
622+
expect(newState).toMatchObject({
623+
...state,
624+
deltalog: [],
625+
transients: {
626+
error: {
627+
type: 'action/action_invalid',
628+
},
629+
},
630+
});
592631
});
593632
});
594633

@@ -772,3 +811,33 @@ describe('undo / redo with stages', () => {
772811
expect(state.ctx.activePlayers['0']).toBe('B');
773812
});
774813
});
814+
815+
describe('TransientHandlingMiddleware', () => {
816+
const middleware = applyMiddleware(TransientHandlingMiddleware);
817+
let store = null;
818+
819+
beforeEach(() => {
820+
store = createStore(reducer, initialState, middleware);
821+
});
822+
823+
test('regular dispatch result has no transients', () => {
824+
const result = store.dispatch(makeMove('A'));
825+
expect(result).toEqual(
826+
expect.not.objectContaining({ transients: expect.anything() })
827+
);
828+
expect(result).toEqual(
829+
expect.not.objectContaining({ stripTransientsResult: expect.anything() })
830+
);
831+
});
832+
833+
test('failing dispatch result contains transients', () => {
834+
const result = store.dispatch(makeMove('Invalid'));
835+
expect(result).toMatchObject({
836+
transients: {
837+
error: {
838+
type: 'action/invalid_move',
839+
},
840+
},
841+
});
842+
});
843+
});

0 commit comments

Comments
 (0)