Skip to content

Commit

Permalink
Merge pull request #403 from ulixee/oob
Browse files Browse the repository at this point in the history
feat(core): dialogs should run out of command line
  • Loading branch information
calebjclark authored Dec 20, 2021
2 parents c55f93e + c02f61b commit 05f7132
Show file tree
Hide file tree
Showing 15 changed files with 135 additions and 51 deletions.
2 changes: 1 addition & 1 deletion client/lib/Agent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -421,7 +421,7 @@ export default class Agent extends AwaitedEventTarget<{ close: void }> {
return this.activeTab.waitForFileChooser(options);
}

public waitForLocation(trigger: ILocationTrigger, options?: IWaitForOptions): Promise<void> {
public waitForLocation(trigger: ILocationTrigger, options?: IWaitForOptions): Promise<Resource> {
return this.activeTab.waitForLocation(trigger, options);
}

Expand Down
40 changes: 29 additions & 11 deletions client/lib/CoreCommandQueue.ts
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,15 @@ export default class CoreCommandQueue {
});
}

public async runOutOfBand<T>(command: string, ...args: any[]): Promise<T> {
return await this.sendRequest({
command,
args,
commandId: this.nextCommandId,
startDate: new Date(),
});
}

public run<T>(command: string, ...args: any[]): Promise<T> {
clearTimeout(this.flushOnTimeout);
this.flushOnTimeout = null;
Expand All @@ -94,27 +103,19 @@ export default class CoreCommandQueue {
convertJsPathArgs(arg);
}
}
const startTime = new Date();
const startDate = new Date();
const commandId = this.nextCommandId;
return this.internalQueue
.run<T>(async () => {
const recordCommands = [...this.internalState.commandsToRecord];
this.internalState.commandsToRecord.length = 0;

const response = await this.connection.sendRequest({
meta: this.meta,
return await this.sendRequest<T>({
command,
args,
startDate: startTime,
startDate,
commandId,
recordCommands,
});

let data: T = null;
if (response) {
data = response.data;
}
return data;
})
.catch(error => {
error.stack += `${this.sessionMarker}`;
Expand All @@ -133,4 +134,21 @@ export default class CoreCommandQueue {
public createSharedQueue(meta: ISessionMeta & { sessionName: string }): CoreCommandQueue {
return new CoreCommandQueue(meta, this.connection, this.commandCounter, this.internalState);
}

private async sendRequest<T>(
payload: Omit<ICoreRequestPayload, 'meta' | 'messageId' | 'sendDate'>,
): Promise<T> {
if (this.connection.isDisconnecting) {
return Promise.resolve(null);
}

const response = await this.connection.sendRequest({
meta: this.meta,
...payload,
});

if (response) {
return response.data;
}
}
}
8 changes: 6 additions & 2 deletions client/lib/CoreFrameEnvironment.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import INodePointer from 'awaited-dom/base/INodePointer';
import ISetCookieOptions from '@secret-agent/interfaces/ISetCookieOptions';
import IWaitForOptions from '@secret-agent/interfaces/IWaitForOptions';
import IFrameMeta from '@secret-agent/interfaces/IFrameMeta';
import IResourceMeta from '@secret-agent/interfaces/IResourceMeta';
import CoreCommandQueue from './CoreCommandQueue';

export default class CoreFrameEnvironment {
Expand Down Expand Up @@ -109,7 +110,10 @@ export default class CoreFrameEnvironment {
await this.commandQueue.run('FrameEnvironment.waitForLoad', status, opts);
}

public async waitForLocation(trigger: ILocationTrigger, opts: IWaitForOptions): Promise<void> {
await this.commandQueue.run('FrameEnvironment.waitForLocation', trigger, opts);
public async waitForLocation(
trigger: ILocationTrigger,
opts: IWaitForOptions,
): Promise<IResourceMeta> {
return await this.commandQueue.run('FrameEnvironment.waitForLocation', trigger, opts);
}
}
5 changes: 3 additions & 2 deletions client/lib/CoreTab.ts
Original file line number Diff line number Diff line change
Expand Up @@ -84,7 +84,7 @@ export default class CoreTab implements IJsPathEventTarget {
}

public async getResourceProperty<T = any>(id: number, propertyPath: string): Promise<T> {
return await this.commandQueue.run('Tab.getResourceProperty', id, propertyPath);
return await this.commandQueue.runOutOfBand('Tab.getResourceProperty', id, propertyPath);
}

public async configure(options: IConfigureSessionOptions): Promise<void> {
Expand Down Expand Up @@ -146,7 +146,8 @@ export default class CoreTab implements IJsPathEventTarget {
}

public async dismissDialog(accept: boolean, promptText?: string): Promise<void> {
await this.commandQueue.run('Tab.dismissDialog', accept, promptText);
// NOTE: since this can only be called from inside event handlers, it should not go into the queue or it can deadlock
await this.commandQueue.runOutOfBand('Tab.dismissDialog', accept, promptText);
}

public async addEventListener(
Expand Down
11 changes: 8 additions & 3 deletions client/lib/FrameEnvironment.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,10 +33,12 @@ import CookieStorage, { createCookieStorage } from './CookieStorage';
import Agent, { IState as IAgentState } from './Agent';
import { delegate as AwaitedHandler, getAwaitedPathAsMethodArg } from './SetupAwaitedHandler';
import CoreFrameEnvironment from './CoreFrameEnvironment';
import Tab from './Tab';
import Tab, { IState as ITabState } from './Tab';
import { IMousePosition } from '../interfaces/IInteractions';
import Resource, { createResource } from './Resource';

const { getState, setState } = StateMachine<FrameEnvironment, IState>();
const { getState: getTabState } = StateMachine<Tab, ITabState>();
const agentState = StateMachine<Agent, IAgentState>();
const awaitedPathState = StateMachine<
any,
Expand Down Expand Up @@ -204,9 +206,12 @@ export default class FrameEnvironment {
public async waitForLocation(
trigger: ILocationTrigger,
options?: IWaitForOptions,
): Promise<void> {
): Promise<Resource> {
const coreFrame = await getCoreFrameEnvironment(this);
await coreFrame.waitForLocation(trigger, options);
const resourceMeta = await coreFrame.waitForLocation(trigger, options);
const { tab } = getState(this);
const { coreTab } = getTabState(tab);
return createResource(coreTab, resourceMeta);
}

public toJSON(): any {
Expand Down
2 changes: 1 addition & 1 deletion client/lib/Tab.ts
Original file line number Diff line number Diff line change
Expand Up @@ -227,7 +227,7 @@ export default class Tab extends AwaitedEventTarget<IEventType> {
public async waitForLocation(
trigger: ILocationTrigger,
options?: IWaitForOptions,
): Promise<void> {
): Promise<Resource> {
return await this.mainFrameEnvironment.waitForLocation(trigger, options);
}

Expand Down
26 changes: 20 additions & 6 deletions core/lib/FrameEnvironment.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ import TypeSerializer from '@secret-agent/commons/TypeSerializer';
import * as Os from 'os';
import ICommandMeta from '@secret-agent/interfaces/ICommandMeta';
import IPoint from '@secret-agent/interfaces/IPoint';
import IResourceMeta from '@secret-agent/interfaces/IResourceMeta';
import SessionState from './SessionState';
import TabNavigationObserver from './FrameNavigationsObserver';
import Session from './Session';
Expand Down Expand Up @@ -89,12 +90,12 @@ export default class FrameEnvironment {
public puppetFrame: IPuppetFrame;
public isReady: Promise<Error | void>;
public domNodeId: number;
public readonly interactor: Interactor;
protected readonly logger: IBoundLog;

private puppetNodeIdsBySaNodeId: Record<number, string> = {};
private prefetchedJsPaths: IJsPathResult[];
private readonly isDetached: boolean;
private readonly interactor: Interactor;
private isClosing = false;
private waitTimeouts: { timeout: NodeJS.Timeout; reject: (reason?: any) => void }[] = [];
private readonly commandRecorder: CommandRecorder;
Expand Down Expand Up @@ -183,7 +184,7 @@ export default class FrameEnvironment {
if (this.isDetached) {
throw new Error("Sorry, you can't interact with a detached frame");
}
await this.navigationsObserver.waitForReady();
await this.navigationsObserver.waitForLoad(LoadStatus.DomContentLoaded);
const interactionResolvable = createPromise<void>(120e3);
this.waitTimeouts.push({
timeout: interactionResolvable.timeout,
Expand Down Expand Up @@ -218,7 +219,7 @@ export default class FrameEnvironment {
public async execJsPath<T>(jsPath: IJsPath): Promise<IExecJsPathResult<T>> {
// if nothing loaded yet, return immediately
if (!this.navigations.top) return null;
await this.navigationsObserver.waitForReady();
await this.navigationsObserver.waitForLoad(LoadStatus.DomContentLoaded);
const containerOffset = await this.getContainerOffset();
return await this.jsPath.exec(jsPath, containerOffset);
}
Expand Down Expand Up @@ -336,7 +337,7 @@ b) Use the UserProfile feature to set cookies for 1 or more domains before they'
}

public async getChildFrameEnvironment(jsPath: IJsPath): Promise<IFrameMeta> {
await this.navigationsObserver.waitForReady();
await this.navigationsObserver.waitForLoad(LoadStatus.DomContentLoaded);
const nodeIdResult = await this.jsPath.exec<number>([...jsPath, [getNodeIdFnName]], null);
if (!nodeIdResult.value) return null;

Expand Down Expand Up @@ -369,8 +370,21 @@ b) Use the UserProfile feature to set cookies for 1 or more domains before they'
return this.navigationsObserver.waitForLoad(status, options);
}

public waitForLocation(trigger: ILocationTrigger, options?: IWaitForOptions): Promise<void> {
return this.navigationsObserver.waitForLocation(trigger, options);
public async waitForLocation(
trigger: ILocationTrigger,
options?: IWaitForOptions,
): Promise<IResourceMeta> {
const timer = new Timer(options?.timeoutMs ?? 60e3, this.waitTimeouts);
await timer.waitForPromise(
this.navigationsObserver.waitForLocation(trigger, options),
`Timeout waiting for location ${trigger}`,
);

const resource = await timer.waitForPromise(
this.navigationsObserver.waitForNavigationResourceId(),
`Timeout waiting for location ${trigger}`,
);
return this.sessionState.getResourceMeta(resource);
}

// NOTE: don't add this function to commands. It will record extra commands when called from interactor, which
Expand Down
2 changes: 1 addition & 1 deletion core/lib/FrameNavigationsObserver.ts
Original file line number Diff line number Diff line change
Expand Up @@ -96,7 +96,7 @@ export default class FrameNavigationsObserver {
}

public waitForReady(): Promise<void> {
return this.waitForLoad(LocationStatus.DomContentLoaded);
return this.waitForLoad(LocationStatus.HttpResponded);
}

public async waitForNavigationResourceId(): Promise<number> {
Expand Down
65 changes: 46 additions & 19 deletions core/lib/Tab.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ import IWaitForOptions from '@secret-agent/interfaces/IWaitForOptions';
import IScreenshotOptions from '@secret-agent/interfaces/IScreenshotOptions';
import MitmRequestContext from '@secret-agent/mitm/lib/MitmRequestContext';
import { IJsPath } from 'awaited-dom/base/AwaitedPath';
import { IInteractionGroups } from '@secret-agent/interfaces/IInteractions';
import { IInteractionGroups, InteractionCommand } from '@secret-agent/interfaces/IInteractions';
import IExecJsPathResult from '@secret-agent/interfaces/IExecJsPathResult';
import IWaitForElementOptions from '@secret-agent/interfaces/IWaitForElementOptions';
import { ILocationTrigger, IPipelineStatus } from '@secret-agent/interfaces/Location';
Expand Down Expand Up @@ -167,6 +167,8 @@ export default class Tab extends TypedEventEmitter<ITabEventParams> {
resource: IResourceMeta,
error?: Error,
): boolean {
if (resource.type !== 'Document') return;

const frame = this.findFrameWithUnresolvedNavigation(
browserRequestId,
resource.request?.method,
Expand All @@ -186,8 +188,6 @@ export default class Tab extends TypedEventEmitter<ITabEventParams> {
requestedUrl: string,
finalUrl: string,
): FrameEnvironment {
if (method !== 'GET') return null;

for (const frame of this.frameEnvironmentsById.values()) {
const top = frame.navigations.top;
if (!top || top.resourceId.isResolved) continue;
Expand Down Expand Up @@ -292,7 +292,6 @@ export default class Tab extends TypedEventEmitter<ITabEventParams> {
let finalResourceId = resourceId;
// if no resource id, this is a request for the default resource (page)
if (!resourceId) {
await this.navigationsObserver.waitForReady();
finalResourceId = await this.navigationsObserver.waitForNavigationResourceId();
}

Expand Down Expand Up @@ -341,7 +340,10 @@ export default class Tab extends TypedEventEmitter<ITabEventParams> {
return this.mainFrameEnvironment.waitForLoad(status, options);
}

public waitForLocation(trigger: ILocationTrigger, options?: IWaitForOptions): Promise<void> {
public waitForLocation(
trigger: ILocationTrigger,
options?: IWaitForOptions,
): Promise<IResourceMeta> {
return this.mainFrameEnvironment.waitForLocation(trigger, options);
}

Expand Down Expand Up @@ -421,8 +423,19 @@ export default class Tab extends TypedEventEmitter<ITabEventParams> {
const timer = new Timer(timeoutMs, this.waitTimeouts);
const timeoutMessage = `Timeout waiting for "tab.reload()"`;

let loaderId = this.puppetPage.mainFrame.activeLoader.id;
await timer.waitForPromise(this.puppetPage.reload(), timeoutMessage);
this.navigations.assignLoaderId(navigation, this.puppetPage.mainFrame.activeLoader?.id);
if (this.puppetPage.mainFrame.activeLoader.id === loaderId) {
const frameNavigated = await timer.waitForPromise(
this.puppetPage.mainFrame.waitOn('frame-navigated', null, timeoutMs),
timeoutMessage,
);
loaderId = frameNavigated.loaderId;
}
this.navigations.assignLoaderId(
navigation,
loaderId ?? this.puppetPage.mainFrame.activeLoader?.id,
);

const resource = await timer.waitForPromise(
this.navigationsObserver.waitForNavigationResourceId(),
Expand All @@ -440,7 +453,13 @@ export default class Tab extends TypedEventEmitter<ITabEventParams> {
return this.puppetPage.screenshot(options.format, options.rectangle, options.jpegQuality);
}

public dismissDialog(accept: boolean, promptText?: string): Promise<void> {
public async dismissDialog(accept: boolean, promptText?: string): Promise<void> {
const resolvable = createPromise();
this.mainFrameEnvironment.interactor.play(
[[{ command: InteractionCommand.willDismissDialog }]],
resolvable,
);
await resolvable.promise;
return this.puppetPage.dismissDialog(accept, promptText);
}

Expand Down Expand Up @@ -745,22 +764,30 @@ export default class Tab extends TypedEventEmitter<ITabEventParams> {
return;
}

if (
!!event.resource.browserServedFromCache &&
event.resource.url?.href === frame.navigations?.top?.requestedUrl &&
frame.navigations?.top?.resourceId?.isResolved === false
) {
frame.navigations.onHttpResponded(
event.resource.browserRequestId,
event.resource.responseUrl ?? event.resource.url?.href,
event.loaderId,
);
}

const resourcesWithBrowserRequestId = this.sessionState.getBrowserRequestResources(
event.resource.browserRequestId,
);

const navigationTop = frame.navigations?.top;
if (navigationTop && !navigationTop.resourceId.isResolved) {
const url = event.resource.url?.href;
// hash won't be in the http request
const frameRequestedUrl = navigationTop.requestedUrl?.split('#')?.shift();
if (url === frameRequestedUrl) {
if (event.resource.browserServedFromCache) {
frame.navigations.onHttpResponded(
event.resource.browserRequestId,
event.resource.responseUrl ?? event.resource.url?.href,
event.loaderId,
);
}
if (resourcesWithBrowserRequestId?.length) {
const resource = resourcesWithBrowserRequestId[resourcesWithBrowserRequestId.length - 1];
frame.navigations.onResourceLoaded(resource.resourceId, event.resource.status);
}
}
}

if (!resourcesWithBrowserRequestId?.length) {
// first check if this is a mitm error
const errorsMatchingUrl = this.session.mitmErrorsByUrl.get(event.resource.url.href);
Expand Down
8 changes: 8 additions & 0 deletions core/test/navigation.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,14 @@ describe('basic Navigation tests', () => {
expect(formattedUrl).toBe(`${unformattedUrl}/`);
});

it('handles urls with a hash', async () => {
koaServer.get('/hash', ctx => {
ctx.body = 'done';
});
const { tab } = await createSession();
await expect(tab.goto(`${koaServer.baseUrl}/hash#hash`)).resolves.toBeTruthy();
});

it('works without explicit waitForLocation', async () => {
const { tab } = await createSession();
await tab.goto(koaServer.baseUrl);
Expand Down
4 changes: 2 additions & 2 deletions full-client/test/waitForLocation.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -178,7 +178,7 @@ describe('basic waitForLocation change detections', () => {
const agent = await handler.createAgent();
await agent.goto(`${koaServer.baseUrl}/refresh`);

await expect(agent.waitForLocation('reload')).resolves.toBe(undefined);
await expect(agent.waitForLocation('reload')).resolves.toBeTruthy();
});

it('will trigger reload if the same page is loaded again', async () => {
Expand Down Expand Up @@ -207,6 +207,6 @@ describe('basic waitForLocation change detections', () => {
await agent.goto(`${koaServer.baseUrl}/postback`);
await agent.click(agent.activeTab.document.querySelector('input'));

await expect(agent.waitForLocation('reload')).resolves.toBe(undefined);
await expect(agent.waitForLocation('reload')).resolves.toBeTruthy();
});
});
Loading

0 comments on commit 05f7132

Please sign in to comment.