fix(platform): stabilize store snapshots and notifications

This commit is contained in:
2026-09-05 03:29:49 +08:00
parent 462e2e9267
commit 9840ed70b2
2 changed files with 383 additions and 33 deletions
@@ -1,4 +1,4 @@
import { signal, type Reactive, type Unsubscribe } from '../../core/reactive.ts'; import type { Unsubscribe } from '../../core/reactive.ts';
import { import {
hasOwn, hasOwn,
requireArray, requireArray,
@@ -74,10 +74,70 @@ function roomtypePropertyPath(path: string, key: string, isArray: boolean): stri
return `${path}[${JSON.stringify(key)}]`; return `${path}[${JSON.stringify(key)}]`;
} }
interface RoomtypePropertySnapshot {
readonly key: PropertyKey;
readonly path: string;
readonly descriptor: PropertyDescriptor;
}
interface RoomtypeObjectSnapshot {
readonly source: object;
readonly path: string;
readonly isArray: boolean;
readonly prototype: object | null;
readonly keys: readonly PropertyKey[];
readonly properties: readonly RoomtypePropertySnapshot[];
}
function samePropertyDescriptor(
first: PropertyDescriptor,
second: PropertyDescriptor,
): boolean {
const firstIsData = 'value' in first;
const secondIsData = 'value' in second;
if (firstIsData !== secondIsData) return false;
if (
first.enumerable !== second.enumerable
|| first.configurable !== second.configurable
) return false;
if (firstIsData && secondIsData) {
return first.writable === second.writable && Object.is(first.value, second.value);
}
return Object.is(first.get, second.get) && Object.is(first.set, second.set);
}
function verifyRoomtypeSourceGraph(graph: readonly RoomtypeObjectSnapshot[]): void {
for (const snapshot of graph) {
if (Array.isArray(snapshot.source) !== snapshot.isArray) {
throw new TypeError(`${snapshot.path}: unstable roomtype container kind`);
}
if (!Object.is(Object.getPrototypeOf(snapshot.source), snapshot.prototype)) {
throw new TypeError(`${snapshot.path}: unstable roomtype prototype`);
}
const keys = Reflect.ownKeys(snapshot.source);
if (
keys.length !== snapshot.keys.length
|| keys.some((key, index) => !Object.is(key, snapshot.keys[index]))
) {
throw new TypeError(`${snapshot.path}: unstable roomtype own keys`);
}
for (const property of snapshot.properties) {
const descriptor = Reflect.getOwnPropertyDescriptor(snapshot.source, property.key);
if (
descriptor === undefined
|| !samePropertyDescriptor(property.descriptor, descriptor)
) {
throw new TypeError(`${property.path}: unstable roomtype property descriptor`);
}
}
}
}
function cloneAndFreezeRoomtype(value: readonly unknown[]): readonly unknown[] { function cloneAndFreezeRoomtype(value: readonly unknown[]): readonly unknown[] {
const copies = new WeakMap<object, object>(); const copies = new WeakMap<object, object>();
const active = new WeakSet<object>(); const active = new WeakSet<object>();
const ownedGraph: object[] = []; const ownedGraph: object[] = [];
const sourceGraph: RoomtypeObjectSnapshot[] = [];
const copy = (current: unknown, path: string): unknown => { const copy = (current: unknown, path: string): unknown => {
if (current === null) return null; if (current === null) return null;
@@ -103,40 +163,47 @@ function cloneAndFreezeRoomtype(value: readonly unknown[]): readonly unknown[] {
throw new TypeError(`${path}: expected JSON array or plain object container`); throw new TypeError(`${path}: expected JSON array or plain object container`);
} }
const keys = Reflect.ownKeys(current);
const properties: RoomtypePropertySnapshot[] = [];
for (const key of keys) {
if (typeof key !== 'string') {
throw new TypeError(`${path}: symbol properties are not valid roomtype data`);
}
const propertyPath = roomtypePropertyPath(path, key, sourceIsArray);
const descriptor = Reflect.getOwnPropertyDescriptor(current, key);
if (descriptor === undefined) {
throw new TypeError(`${propertyPath}: unstable property structure`);
}
properties.push({ key, path: propertyPath, descriptor });
}
const lengthProperty = properties.find((property) => property.key === 'length');
let target: unknown[] | Record<string, unknown>; let target: unknown[] | Record<string, unknown>;
let arrayLength = 0; let arrayLength = 0;
if (sourceIsArray) { if (sourceIsArray) {
const lengthDescriptor = Reflect.getOwnPropertyDescriptor(current, 'length'); if (lengthProperty === undefined || !('value' in lengthProperty.descriptor)) {
if (lengthDescriptor === undefined || !('value' in lengthDescriptor)) {
throw new TypeError(`${path}.length: expected stable data property`); throw new TypeError(`${path}.length: expected stable data property`);
} }
arrayLength = requireInteger(lengthDescriptor.value, `${path}.length`); arrayLength = requireInteger(lengthProperty.descriptor.value, `${path}.length`);
if (arrayLength < 0) throw new RangeError(`${path}.length: expected non-negative integer`); if (arrayLength < 0) throw new RangeError(`${path}.length: expected non-negative integer`);
target = new Array(arrayLength); target = new Array(arrayLength);
} else { } else {
target = prototype === null ? Object.create(null) as Record<string, unknown> : {}; target = prototype === null ? Object.create(null) as Record<string, unknown> : {};
} }
sourceGraph.push({ source: current, path, isArray: sourceIsArray, prototype, keys, properties });
copies.set(current, target); copies.set(current, target);
active.add(current); active.add(current);
ownedGraph.push(target); ownedGraph.push(target);
const keys = Reflect.ownKeys(current); for (const property of properties) {
for (const key of keys) { const { key, path: propertyPath, descriptor } = property;
if (sourceIsArray && key === 'length') continue; if (sourceIsArray && key === 'length') continue;
if (typeof key !== 'string') {
throw new TypeError(`${path}: symbol properties are not valid roomtype data`);
}
const propertyPath = roomtypePropertyPath(path, key, sourceIsArray);
if (sourceIsArray) { if (sourceIsArray) {
const index = Number(key); const index = Number(key);
if (!Number.isInteger(index) || index < 0 || index >= arrayLength || String(index) !== key) { if (!Number.isInteger(index) || index < 0 || index >= arrayLength || String(index) !== key) {
throw new TypeError(`${propertyPath}: expected array index`); throw new TypeError(`${propertyPath}: expected array index`);
} }
} }
const descriptor = Reflect.getOwnPropertyDescriptor(current, key);
if (descriptor === undefined) {
throw new TypeError(`${propertyPath}: unstable property structure`);
}
if (!('value' in descriptor)) { if (!('value' in descriptor)) {
throw new TypeError(`${propertyPath}: accessor properties are not allowed in roomtype`); throw new TypeError(`${propertyPath}: accessor properties are not allowed in roomtype`);
} }
@@ -156,6 +223,7 @@ function cloneAndFreezeRoomtype(value: readonly unknown[]): readonly unknown[] {
}; };
const canonical = copy(value, 'roomtype') as readonly unknown[]; const canonical = copy(value, 'roomtype') as readonly unknown[];
verifyRoomtypeSourceGraph(sourceGraph);
for (let index = ownedGraph.length - 1; index >= 0; index -= 1) { for (let index = ownedGraph.length - 1; index >= 0; index -= 1) {
Object.freeze(ownedGraph[index]); Object.freeze(ownedGraph[index]);
} }
@@ -323,15 +391,54 @@ const INITIAL_STATE: PlatformState = Object.freeze({
export type PlatformStateListener = (state: PlatformState, previous: PlatformState) => void; export type PlatformStateListener = (state: PlatformState, previous: PlatformState) => void;
interface PlatformTransition {
readonly state: PlatformState;
readonly previous: PlatformState;
readonly listeners: readonly PlatformStateListener[];
}
export class PlatformStore { export class PlatformStore {
private readonly stateCell: Reactive<PlatformState> = signal(INITIAL_STATE); private state: PlatformState = INITIAL_STATE;
private readonly listeners = new Set<PlatformStateListener>();
private readonly transitions: PlatformTransition[] = [];
private publishing = false;
getState(): PlatformState { getState(): PlatformState {
return this.stateCell.value; return this.state;
} }
subscribe(listener: PlatformStateListener): Unsubscribe { subscribe(listener: PlatformStateListener): Unsubscribe {
return this.stateCell.subscribe(listener); this.listeners.add(listener);
return (): void => { this.listeners.delete(listener); };
}
private commit(state: PlatformState): void {
if (Object.is(state, this.state)) return;
const previous = this.state;
this.state = state;
this.transitions.push({
state,
previous,
listeners: [...this.listeners],
});
if (this.publishing) return;
this.publishing = true;
try {
while (this.transitions.length > 0) {
const transition = this.transitions.shift();
if (transition === undefined) continue;
for (const listener of transition.listeners) {
try {
listener(transition.state, transition.previous);
} catch (error) {
console.error('[reactive] subscriber error:', error);
}
}
}
} finally {
this.publishing = false;
}
} }
applyLoginSuccess(parsed: ParsedLoginResponse): void { applyLoginSuccess(parsed: ParsedLoginResponse): void {
@@ -374,11 +481,11 @@ export class PlatformStore {
player, player,
requireProtocolFlag(raw.isbattle, '$.login.raw.isbattle'), requireProtocolFlag(raw.isbattle, '$.login.raw.isbattle'),
); );
this.stateCell.value = Object.freeze({ this.commit(Object.freeze({
app: Object.freeze({ phase: 'logged-in' }), app: Object.freeze({ phase: 'logged-in' }),
players: roomResult.players, players: roomResult.players,
room: roomResult.room, room: roomResult.room,
}); }));
} }
replaceRoom(parsed: ParsedRoomResponse): void { replaceRoom(parsed: ParsedRoomResponse): void {
@@ -423,11 +530,11 @@ export class PlatformStore {
? 1 ? 1
: requireProtocolFlag(raw.deskwar, '$.response.raw.deskwar'); : requireProtocolFlag(raw.deskwar, '$.response.raw.deskwar');
const roomResult = buildRoom(input.room, raw, selfPlayer, stage); const roomResult = buildRoom(input.room, raw, selfPlayer, stage);
this.stateCell.value = Object.freeze({ this.commit(Object.freeze({
app: previous.app, app: previous.app,
players: roomResult.players, players: roomResult.players,
room: roomResult.room, room: roomResult.room,
}); }));
} }
clearRoom(): void { clearRoom(): void {
@@ -441,14 +548,14 @@ export class PlatformStore {
if (selfPlayer === undefined) { if (selfPlayer === undefined) {
throw new Error('players.entities: missing authenticated player'); throw new Error('players.entities: missing authenticated player');
} }
this.stateCell.value = Object.freeze({ this.commit(Object.freeze({
app: previous.app, app: previous.app,
players: Object.freeze({ players: Object.freeze({
selfPlayerId, selfPlayerId,
entities: Object.freeze({ [selfPlayerId]: selfPlayer }), entities: Object.freeze({ [selfPlayerId]: selfPlayer }),
}), }),
room: Object.freeze({ kind: 'outside' }), room: Object.freeze({ kind: 'outside' }),
}); }));
} }
playerJoin(parsed: ParsedSeatPayload): void { playerJoin(parsed: ParsedSeatPayload): void {
@@ -480,7 +587,7 @@ export class PlatformStore {
? [...room.offlineSeats, event.seat] ? [...room.offlineSeats, event.seat]
: [...room.offlineSeats]; : [...room.offlineSeats];
this.stateCell.value = Object.freeze({ this.commit(Object.freeze({
app: previous.app, app: previous.app,
players: Object.freeze({ players: Object.freeze({
selfPlayerId: previous.players.selfPlayerId, selfPlayerId: previous.players.selfPlayerId,
@@ -492,7 +599,7 @@ export class PlatformStore {
readySeats: Object.freeze(readySeats), readySeats: Object.freeze(readySeats),
offlineSeats: Object.freeze(offlineSeats), offlineSeats: Object.freeze(offlineSeats),
}), }),
}); }));
} }
playerReady(parsed: ParsedSeatPayload): void { playerReady(parsed: ParsedSeatPayload): void {
@@ -512,14 +619,14 @@ export class PlatformStore {
[playerid]: Object.freeze({ ...player, isprepare: 1 }), [playerid]: Object.freeze({ ...player, isprepare: 1 }),
}); });
const readySeats = Object.freeze([...room.readySeats, event.seat]); const readySeats = Object.freeze([...room.readySeats, event.seat]);
this.stateCell.value = Object.freeze({ this.commit(Object.freeze({
app: previous.app, app: previous.app,
players: Object.freeze({ players: Object.freeze({
selfPlayerId: previous.players.selfPlayerId, selfPlayerId: previous.players.selfPlayerId,
entities, entities,
}), }),
room: Object.freeze({ ...room, readySeats }), room: Object.freeze({ ...room, readySeats }),
}); }));
} }
playerOffline(parsed: ParsedSeatPayload): void { playerOffline(parsed: ParsedSeatPayload): void {
@@ -539,14 +646,14 @@ export class PlatformStore {
[playerid]: Object.freeze({ ...player, onstate: 1 }), [playerid]: Object.freeze({ ...player, onstate: 1 }),
}); });
const offlineSeats = Object.freeze([...room.offlineSeats, event.seat]); const offlineSeats = Object.freeze([...room.offlineSeats, event.seat]);
this.stateCell.value = Object.freeze({ this.commit(Object.freeze({
app: previous.app, app: previous.app,
players: Object.freeze({ players: Object.freeze({
selfPlayerId: previous.players.selfPlayerId, selfPlayerId: previous.players.selfPlayerId,
entities, entities,
}), }),
room: Object.freeze({ ...room, offlineSeats }), room: Object.freeze({ ...room, offlineSeats }),
}); }));
} }
playerOnline(parsed: ParsedOnlinePayload): void { playerOnline(parsed: ParsedOnlinePayload): void {
@@ -573,14 +680,14 @@ export class PlatformStore {
const offlineSeats = Object.freeze( const offlineSeats = Object.freeze(
room.offlineSeats.filter((seat) => seat !== event.seat), room.offlineSeats.filter((seat) => seat !== event.seat),
); );
this.stateCell.value = Object.freeze({ this.commit(Object.freeze({
app: previous.app, app: previous.app,
players: Object.freeze({ players: Object.freeze({
selfPlayerId: previous.players.selfPlayerId, selfPlayerId: previous.players.selfPlayerId,
entities, entities,
}), }),
room: Object.freeze({ ...room, offlineSeats }), room: Object.freeze({ ...room, offlineSeats }),
}); }));
} }
playerExit(parsed: ParsedSeatPayload): void { playerExit(parsed: ParsedSeatPayload): void {
@@ -601,7 +708,7 @@ export class PlatformStore {
const readySeats = room.readySeats.filter((seat) => seat !== event.seat); const readySeats = room.readySeats.filter((seat) => seat !== event.seat);
const offlineSeats = room.offlineSeats.filter((seat) => seat !== event.seat); const offlineSeats = room.offlineSeats.filter((seat) => seat !== event.seat);
this.stateCell.value = Object.freeze({ this.commit(Object.freeze({
app: previous.app, app: previous.app,
players: Object.freeze({ players: Object.freeze({
selfPlayerId: previous.players.selfPlayerId, selfPlayerId: previous.players.selfPlayerId,
@@ -613,6 +720,6 @@ export class PlatformStore {
readySeats: Object.freeze(readySeats), readySeats: Object.freeze(readySeats),
offlineSeats: Object.freeze(offlineSeats), offlineSeats: Object.freeze(offlineSeats),
}), }),
}); }));
} }
} }
@@ -2,6 +2,7 @@ import { readFileSync } from 'node:fs';
import { test } from 'node:test'; import { test } from 'node:test';
import assert from 'node:assert/strict'; import assert from 'node:assert/strict';
import { PlatformStore } from '../../YouleNexus/assets/framework/platform/stores/platform-store.ts'; import { PlatformStore } from '../../YouleNexus/assets/framework/platform/stores/platform-store.ts';
import type { PlatformState } from '../../YouleNexus/assets/framework/platform/stores/platform-types.ts';
import { import {
parseLoginResponse, parseLoginResponse,
parseOtherExitRoomPayload, parseOtherExitRoomPayload,
@@ -572,3 +573,245 @@ test('cyclic roomtype input is rejected atomically without mutating caller data'
assert.equal(store.getState(), before); assert.equal(store.getState(), before);
assert.equal(notifications, 0); assert.equal(notifications, 0);
}); });
test('roomtype descriptor changes between copy and verification fail atomically', () => {
const sibling = { stable: true };
const target = { value: 0 };
let descriptorReads = 0;
const unstable = new Proxy(target, {
getOwnPropertyDescriptor(object, key): PropertyDescriptor | undefined {
const descriptor = Reflect.getOwnPropertyDescriptor(object, key);
if (key !== 'value' || descriptor === undefined) return descriptor;
descriptorReads += 1;
return { ...descriptor, value: descriptorReads };
},
});
const roomtype = [sibling, unstable];
const data = fixture('self-join-room.json').data;
data.roomtype = roomtype;
const parsed = parseSelfJoinRoomResponse(data);
const store = new PlatformStore();
store.applyLoginSuccess(parseLoginResponse(fixture('player-login-success.json').data));
const before = store.getState();
let notifications = 0;
store.subscribe(() => { notifications += 1; });
assert.throws(() => store.replaceRoom(parsed), /roomtype\[1\]\.value.*unstable/i);
assert.equal(descriptorReads >= 2, true);
assert.equal(target.value, 0);
assert.equal(Object.isFrozen(roomtype), false);
assert.equal(Object.isFrozen(sibling), false);
assert.equal(Object.isFrozen(target), false);
assert.equal(store.getState(), before);
assert.equal(notifications, 0);
});
test('roomtype own-key changes between copy and verification fail atomically', () => {
const target = { first: 1, second: 2 };
let ownKeyReads = 0;
const unstable = new Proxy(target, {
ownKeys(): ArrayLike<string | symbol> {
ownKeyReads += 1;
return ownKeyReads === 1 ? ['first'] : ['second'];
},
});
const roomtype = [unstable];
const data = fixture('self-join-room.json').data;
data.roomtype = roomtype;
const parsed = parseSelfJoinRoomResponse(data);
const store = new PlatformStore();
store.applyLoginSuccess(parseLoginResponse(fixture('player-login-success.json').data));
const before = store.getState();
let notifications = 0;
store.subscribe(() => { notifications += 1; });
assert.throws(() => store.replaceRoom(parsed), /roomtype\[0\].*unstable.*own keys/i);
assert.equal(ownKeyReads >= 2, true);
assert.deepEqual(target, { first: 1, second: 2 });
assert.equal(Object.isFrozen(roomtype), false);
assert.equal(Object.isFrozen(target), false);
assert.equal(store.getState(), before);
assert.equal(notifications, 0);
});
test('canonical roomtype preserves sparse length, holes, and negative zero', () => {
const sparse = new Array<unknown>(5);
sparse[1] = -0;
sparse[4] = { tail: true };
const data = fixture('player-login-room.json').data;
data.roomtype = sparse;
const store = new PlatformStore();
store.applyLoginSuccess(parseLoginResponse(data));
const state = store.getState();
assert.equal(state.room.kind, 'inside');
if (state.room.kind !== 'inside') return;
assert.equal(state.room.roomtype.length, 5);
assert.equal(0 in state.room.roomtype, false);
assert.equal(1 in state.room.roomtype, true);
assert.equal(Object.is(state.room.roomtype[1], -0), true);
assert.equal(2 in state.room.roomtype, false);
assert.equal(3 in state.room.roomtype, false);
assert.deepEqual(state.room.roomtype[4], { tail: true });
assert.equal(Object.isFrozen(state.room.roomtype[4]), true);
assert.equal(Object.isFrozen(sparse), false);
assert.equal(Object.isFrozen(sparse[4]), false);
});
test('canonical roomtype keeps own __proto__ data without prototype pollution', () => {
const source = {} as Record<string, unknown>;
Object.defineProperty(source, '__proto__', {
configurable: true,
enumerable: true,
writable: true,
value: { polluted: true },
});
const data = fixture('player-login-room.json').data;
data.roomtype = [source];
const store = new PlatformStore();
store.applyLoginSuccess(parseLoginResponse(data));
const state = store.getState();
assert.equal(state.room.kind, 'inside');
if (state.room.kind !== 'inside') return;
const canonical = state.room.roomtype[0] as Record<string, unknown>;
assert.equal(Object.getPrototypeOf(canonical), Object.prototype);
assert.equal(Object.prototype.hasOwnProperty.call(canonical, '__proto__'), true);
assert.deepEqual(canonical.__proto__, { polluted: true });
assert.equal(({} as { polluted?: boolean }).polluted, undefined);
assert.equal(Object.isFrozen(canonical.__proto__), true);
});
test('non-JSON roomtype own data is rejected without state or caller mutation', () => {
const cases: ReadonlyArray<readonly [string, () => unknown[]]> = [
['symbol', () => {
const source = { value: 1 } as Record<PropertyKey, unknown>;
source[Symbol('hidden')] = 2;
return [source];
}],
['enumerable', () => {
const source = { value: 1 };
Object.defineProperty(source, 'hidden', { enumerable: false, value: 2 });
return [source];
}],
['finite', () => [Number.NaN]],
];
for (const [error, makeRoomtype] of cases) {
const roomtype = makeRoomtype();
const data = fixture('player-login-room.json').data;
data.roomtype = roomtype;
const parsed = parseLoginResponse(data);
const store = new PlatformStore();
const before = store.getState();
let notifications = 0;
store.subscribe(() => { notifications += 1; });
assert.throws(() => store.applyLoginSuccess(parsed), new RegExp(error, 'i'));
assert.equal(Object.isFrozen(roomtype), false);
if (typeof roomtype[0] === 'object' && roomtype[0] !== null) {
assert.equal(Object.isFrozen(roomtype[0]), false);
}
assert.equal(store.getState(), before);
assert.equal(notifications, 0);
}
});
test('reentrant store commits publish each stable transition before the next', () => {
const store = new PlatformStore();
const events: string[] = [];
let nested = false;
const describe = (label: string, next: PlatformState, previous: PlatformState): void => {
events.push(`${label}:${next.room.kind}<-${previous.room.kind}`);
};
store.subscribe((next, previous) => {
describe('A', next, previous);
if (!nested && next.room.kind === 'inside' && previous.room.kind === 'outside') {
nested = true;
store.clearRoom();
}
});
store.subscribe((next, previous) => { describe('B', next, previous); });
store.applyLoginSuccess(parseLoginResponse(fixture('player-login-room.json').data));
assert.deepEqual(events, [
'A:inside<-outside',
'B:inside<-outside',
'A:outside<-inside',
'B:outside<-inside',
]);
assert.equal(store.getState().room.kind, 'outside');
});
test('multiple reentrant commits retain their real previous and next states', () => {
const store = new PlatformStore();
const events: string[] = [];
let nested = false;
store.subscribe((next, previous) => {
events.push(`A:${next.room.kind}<-${previous.room.kind}`);
if (!nested && next.room.kind === 'inside' && previous.room.kind === 'outside') {
nested = true;
store.clearRoom();
assert.equal(store.getState().room.kind, 'outside');
store.applyLoginSuccess(parseLoginResponse(fixture('player-login-room.json').data));
assert.equal(store.getState().room.kind, 'inside');
}
});
store.subscribe((next, previous) => {
events.push(`B:${next.room.kind}<-${previous.room.kind}`);
});
store.applyLoginSuccess(parseLoginResponse(fixture('player-login-room.json').data));
assert.deepEqual(events, [
'A:inside<-outside', 'B:inside<-outside',
'A:outside<-inside', 'B:outside<-inside',
'A:inside<-outside', 'B:inside<-outside',
]);
});
test('subscription changes during publish apply only to later transition snapshots', () => {
const store = new PlatformStore();
const events: string[] = [];
let nested = false;
let unsubscribeB = (): void => undefined;
store.subscribe((next, previous) => {
events.push(`A:${next.room.kind}<-${previous.room.kind}`);
if (!nested && next.room.kind === 'inside') {
nested = true;
unsubscribeB();
store.subscribe((laterNext, laterPrevious) => {
events.push(`C:${laterNext.room.kind}<-${laterPrevious.room.kind}`);
});
store.clearRoom();
}
});
unsubscribeB = store.subscribe((next, previous) => {
events.push(`B:${next.room.kind}<-${previous.room.kind}`);
});
store.applyLoginSuccess(parseLoginResponse(fixture('player-login-room.json').data));
assert.deepEqual(events, [
'A:inside<-outside',
'B:inside<-outside',
'A:outside<-inside',
'C:outside<-inside',
]);
});
test('subscriber exceptions remain isolated from later store listeners', () => {
const store = new PlatformStore();
let reached = false;
const originalConsoleError = console.error;
console.error = (): void => undefined;
try {
store.subscribe(() => { throw new Error('subscriber boom'); });
store.subscribe(() => { reached = true; });
store.applyLoginSuccess(parseLoginResponse(fixture('player-login-success.json').data));
} finally {
console.error = originalConsoleError;
}
assert.equal(reached, true);
});