change: add debugger state machine to WebAudioEventObserver

This commit is contained in:
Michael "Z" Goddard 2022-02-10 03:57:40 -05:00
Родитель adc7dad243
Коммит 4399ae9e2d
3 изменённых файлов: 314 добавлений и 11 удалений

Просмотреть файл

@ -52,7 +52,7 @@ function noopChrome() {
getURL(url) {
return url;
},
lastError: {message: ''},
lastError: undefined,
onConnect: noopEvent(),
},
};

Просмотреть файл

@ -3,7 +3,7 @@
import {beforeEach, describe, expect, it, jest} from '@jest/globals';
import {chrome} from '../chrome';
import {Events} from '../chrome/DebuggerWebAudioDomain';
import {Event} from '../chrome/DebuggerWebAudioDomain';
import {WebAudioEventObserver} from './WebAudioEventObserver';
@ -53,12 +53,19 @@ describe('WebAudioEventObserver', () => {
if (jest.isMockFunction(chrome.debugger.attach)) {
/** @type {function} */ (chrome.debugger.attach.mock.calls[0][2])();
}
expect(chrome.debugger.sendCommand).toBeCalledTimes(1);
if (jest.isMockFunction(chrome.debugger.sendCommand)) {
/** @type {function} */ (chrome.debugger.sendCommand.mock.calls[0][2])();
}
unsubscribe();
expect(chrome.debugger.detach).toBeCalled();
if (jest.isMockFunction(chrome.debugger.sendCommand)) {
/** @type {function} */ (chrome.debugger.sendCommand.mock.calls[1][2])();
}
expect(chrome.debugger.sendCommand).toBeCalledTimes(2);
if (jest.isMockFunction(chrome.debugger.detach)) {
/** @type {function} */ (chrome.debugger.detach.mock.calls[0][1])();
}
expect(chrome.debugger.sendCommand).toBeCalledTimes(2);
expect(chrome.debugger.onDetach.removeListener).toBeCalled();
expect(chrome.debugger.onEvent.removeListener).toBeCalled();
});
@ -85,10 +92,10 @@ describe('WebAudioEventObserver', () => {
if (jest.isMockFunction(chrome.debugger.onEvent.addListener)) {
/** @type {function} */ (
chrome.debugger.onEvent.addListener.mock.calls[0][0]
)('tab', Events.contextCreated, contextCreated);
)('tab', Event.contextCreated, contextCreated);
}
expect(nextMock).toBeCalledWith({
method: Events.contextCreated,
method: Event.contextCreated,
params: contextCreated,
});
});

Просмотреть файл

@ -1,5 +1,21 @@
import {
BehaviorSubject,
catchError,
concat,
defer,
distinctUntilChanged,
map,
merge,
Observable,
ObservedValueOf,
of,
scan,
share,
tap,
} from 'rxjs';
import {chrome} from '../chrome';
import {Methods} from '../chrome/DebuggerWebAudioDomain';
import {Method} from '../chrome/DebuggerWebAudioDomain';
import {Observer} from '../utils/Observer';
import {Audion} from './Types';
@ -11,6 +27,10 @@ const {tabId} = chrome.devtools.inspectedWindow;
* @alias WebAudioEventObserver
*/
export class WebAudioEventObserver extends Observer<Audion.WebAudioEvent> {
permission: PermissionSubject;
attachInterest: CounterSubject;
eventInterest: CounterSubject;
/**
* Observe WebAudio events from chrome.debugger.
*/
@ -33,19 +53,295 @@ export class WebAudioEventObserver extends Observer<Audion.WebAudioEvent> {
chrome.debugger.onDetach.addListener(onDetach);
chrome.debugger.onEvent.addListener(onEvent);
this.attachInterest.increment();
this.eventInterest.increment();
return () => {
chrome.debugger.onDetach.removeListener(onDetach);
chrome.debugger.onEvent.removeListener(onEvent);
chrome.debugger.sendCommand({tabId}, Methods.disable);
chrome.debugger.detach({tabId}, () => {});
this.eventInterest.decrement();
this.attachInterest.decrement();
};
});
const permission = (this.permission = new PermissionSubject());
const attachInterest = (this.attachInterest = new CounterSubject(0));
const attachSubject = new AttachStateSubject();
const eventInterest = (this.eventInterest = new CounterSubject(0));
const eventSubject = new EventStateSubject();
const debuggerState = merge(
permission.pipe(map((permission) => ({permission}))),
attachInterest.pipe(map((attachInterest) => ({attachInterest}))),
attachSubject.pipe(map((attachState) => ({attachState}))),
eventInterest.pipe(map((eventInterest) => ({eventInterest}))),
eventSubject.pipe(map((eventState) => ({eventState}))),
).pipe(
scan((accum, value) => ({...accum, ...value}), {
permission: AttachPermission.UNKNOWN,
attachInterest: 0,
attachState: AttachState.DETACHED,
eventInterest: 0,
eventState: EventState.DISABLED,
}),
distinctUntilChanged(
(previous, current) =>
previous.permission === current.permission &&
previous.attachInterest === current.attachInterest &&
previous.attachState === current.attachState &&
previous.eventInterest === current.eventInterest &&
previous.eventState === current.eventState,
),
share(),
);
debuggerState.subscribe({
next: (state) => {
if (
state.permission === AttachPermission.TEMPORARY &&
state.attachInterest > 0
) {
attachSubject.attach();
} else {
attachSubject.detach();
}
},
});
onDebuggerDetach.subscribe({
next([, reason]) {
if (reason === 'canceled_by_user') {
permission.reject();
}
attachSubject.next(AttachState.DETACHED);
},
});
debuggerState.subscribe({
next(state) {
if (
state.attachState === AttachState.ATTACHED &&
state.eventInterest > 0
) {
eventSubject.enable();
} else {
if (state.attachState === AttachState.ATTACHED) {
eventSubject.disable();
} else {
eventSubject.next(EventState.DISABLED);
}
}
},
});
}
/** Attaches the chrome.debugger to start observing events. */
attach() {
chrome.debugger.attach({tabId}, debuggerVersion, () => {
chrome.debugger.sendCommand({tabId}, Methods.enable);
});
this.permission.grantTemporary();
}
}
enum AttachState {
DETACHING = 'detaching',
DETACHED = 'detached',
ATTACHING = 'attaching',
ATTACHED = 'attached',
}
function bindChromeCallback<P extends any[]>(
method: (...args: [...params: P, callback: () => void]) => void,
thisArg = null,
) {
return (...args: P) =>
new Observable<void>((subscriber) => {
method.call(thisArg, ...args, () => {
if (chrome.runtime.lastError) {
subscriber.error(chrome.runtime.lastError);
} else {
subscriber.complete();
}
});
});
}
function fromChromeEvent<A extends any[]>(
event: Chrome.Event<(...args: A) => any>,
) {
return new Observable<A>((subscriber) => {
const listener = (...args: A) => {
subscriber.next(args);
};
event.addListener(listener);
return () => {
event.removeListener(listener);
};
});
}
const attach = bindChromeCallback(chrome.debugger.attach, chrome.debugger);
const detach = bindChromeCallback(chrome.debugger.detach, chrome.debugger);
const sendCommand = bindChromeCallback(
chrome.debugger.sendCommand,
chrome.debugger,
);
const onDebuggerEvent = fromChromeEvent<
[target: Chrome.DebuggerDebuggee, method: Method, params: any]
>(chrome.debugger.onEvent);
const onDebuggerDetach = fromChromeEvent<
[target: Chrome.DebuggerDebuggee, reason: string]
>(chrome.debugger.onDetach);
enum AttachPermission {
UNKNOWN,
TEMPORARY,
REJECTED,
}
enum EventState {
DISABLING,
DISABLED,
ENABLING,
ENABLED,
}
class PermissionSubject extends BehaviorSubject<AttachPermission> {
constructor() {
super(AttachPermission.UNKNOWN);
}
grantTemporary() {
if (this.value === AttachPermission.UNKNOWN) {
this.next(AttachPermission.TEMPORARY);
}
}
reject() {
if (this.value !== AttachPermission.REJECTED) {
this.next(AttachPermission.REJECTED);
}
}
}
abstract class TransitionSubject<T> extends BehaviorSubject<T> {
transition(options: {
expectState: T;
transitionState: T;
finalState: T;
errorState?: T;
transit: () => Observable<void>;
}) {
if (this.value === options.expectState) {
concat(
of(options.transitionState),
options.transit(),
defer(() =>
this.value === options.transitionState
? of(options.finalState)
: of(),
),
)
.pipe(
catchError((err) =>
of(
this.value === options.transitionState
? options.errorState || options.expectState
: options.expectState,
),
),
)
.subscribe({next: this.next.bind(this)});
}
}
}
class AttachStateSubject extends TransitionSubject<AttachState> {
private readonly attachTransition = {
expectState: AttachState.DETACHED,
transitionState: AttachState.ATTACHING,
transit: () => attach({tabId}, debuggerVersion),
finalState: AttachState.ATTACHED,
};
private readonly detachTransition = {
expectState: AttachState.ATTACHED,
transitionState: AttachState.DETACHING,
finalState: AttachState.DETACHED,
transit: () => detach({tabId}),
};
constructor() {
super(AttachState.DETACHED);
}
attach() {
this.transition(this.attachTransition);
}
detach() {
this.transition(this.detachTransition);
}
}
class EventStateSubject extends TransitionSubject<EventState> {
private readonly enableTransition = {
expectState: EventState.DISABLED,
transitionState: EventState.ENABLING,
finalState: EventState.ENABLED,
transit: () => sendCommand({tabId}, Method.enable),
};
private readonly disableTransition = {
expectState: EventState.ENABLED,
transitionState: EventState.DISABLING,
finalState: EventState.DISABLED,
errorState: EventState.DISABLED,
transit: () => sendCommand({tabId}, Method.disable),
};
constructor() {
super(EventState.DISABLED);
}
enable() {
this.transition(this.enableTransition);
}
disable() {
this.transition(this.disableTransition);
}
}
class CounterSubject extends BehaviorSubject<number> {
increment() {
this.next(this.value + 1);
}
decrement() {
this.next(this.value - 1);
}
}
function props<
T extends {[key: string]: Observable<any>},
R extends {[key in keyof T]: ObservedValueOf<T[key]>},
>(
source: T,
initialValue: R = (Object.keys(source) as (keyof T)[]).reduce(
(accum, key) => {
accum[key] = undefined;
return accum;
},
{} as R,
),
): Observable<R> {
return merge(
...Object.entries(source).map(([key, property]) =>
property.pipe(map((value) => ({[key]: value}))),
),
).pipe(scan((accum, value) => ({...accum, ...value}), initialValue));
}