mirror of
https://github.com/puppeteer/puppeteer
synced 2024-06-14 14:02:48 +00:00
chore: implement operations (#9717)
This commit is contained in:
parent
f1b337e78b
commit
84845e4901
153
packages/puppeteer-core/src/common/Operation.ts
Normal file
153
packages/puppeteer-core/src/common/Operation.ts
Normal file
@ -0,0 +1,153 @@
|
|||||||
|
/**
|
||||||
|
* Copyright 2023 Google Inc. All rights reserved.
|
||||||
|
*
|
||||||
|
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
* you may not use this file except in compliance with the License.
|
||||||
|
* You may obtain a copy of the License at
|
||||||
|
*
|
||||||
|
* http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
*
|
||||||
|
* Unless required by applicable law or agreed to in writing, software
|
||||||
|
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
* See the License for the specific language governing permissions and
|
||||||
|
* limitations under the License.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type {Awaitable} from './types.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Operations are promises that can have <a
|
||||||
|
* href="https://en.wikipedia.org/wiki/Side_effect_(computer_science)">effects</a>
|
||||||
|
* added on them (through {@link Operation.effect}).
|
||||||
|
*
|
||||||
|
* Semantically-speaking, adding an effect equates to guaranteeing the operation
|
||||||
|
* causes the added effect.
|
||||||
|
*
|
||||||
|
* The first effect that errors will propogate its error back to the operation.
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
*
|
||||||
|
* ```ts
|
||||||
|
* await input.click().effect(async () => {
|
||||||
|
* await page.waitForNavigation();
|
||||||
|
* });
|
||||||
|
* ```
|
||||||
|
*
|
||||||
|
* @remarks
|
||||||
|
*
|
||||||
|
* Adding effects to a completed operation will result in an error. This occurs
|
||||||
|
* when either
|
||||||
|
*
|
||||||
|
* 1. the effects are added asynchronously or
|
||||||
|
* 2. the operation was awaited before effects were added.
|
||||||
|
*
|
||||||
|
* For example for (1),
|
||||||
|
*
|
||||||
|
* ```ts
|
||||||
|
* const operation = input.click();
|
||||||
|
* await new Promise(resolve => setTimeout(resolve, 100));
|
||||||
|
* await operation.effect(() => console.log('Works!')); // This will throw because of (1).
|
||||||
|
* ```
|
||||||
|
*
|
||||||
|
* For example for (2),
|
||||||
|
*
|
||||||
|
* ```ts
|
||||||
|
* const operation = await input.click();
|
||||||
|
* await operation.effect(() => console.log('Works!')); // This will throw because of (2).
|
||||||
|
* ```
|
||||||
|
*
|
||||||
|
* Tl;dr, effects **must** be added synchronously (no `await` statements between
|
||||||
|
* the time the operation is created and the effect is added).
|
||||||
|
*
|
||||||
|
* @internal
|
||||||
|
*/
|
||||||
|
export class Operation<T> extends Promise<T> {
|
||||||
|
/**
|
||||||
|
* @internal
|
||||||
|
*/
|
||||||
|
static create<T>(fn: () => Awaitable<T>, delay = 0): Operation<T> {
|
||||||
|
return new Operation((resolve, reject) => {
|
||||||
|
setTimeout(async () => {
|
||||||
|
try {
|
||||||
|
resolve(await fn());
|
||||||
|
} catch (error) {
|
||||||
|
reject(error);
|
||||||
|
}
|
||||||
|
}, delay);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
#settled = false;
|
||||||
|
#effects: Array<Awaitable<unknown>> = [];
|
||||||
|
#error?: unknown;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Adds the given effect.
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
*
|
||||||
|
* ```ts
|
||||||
|
* await input.click().effect(async () => {
|
||||||
|
* await page.waitForNavigation();
|
||||||
|
* });
|
||||||
|
* ```
|
||||||
|
*
|
||||||
|
* @param effect - The effect to add.
|
||||||
|
* @returns `this` for chaining.
|
||||||
|
*
|
||||||
|
* @public
|
||||||
|
*/
|
||||||
|
effect(effect: () => Awaitable<void>): this {
|
||||||
|
if (this.#settled) {
|
||||||
|
throw new Error(
|
||||||
|
'Attempted to add effect to a completed operation. Make sure effects are added synchronously after the operation is created.'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
this.#effects.push(
|
||||||
|
(async () => {
|
||||||
|
try {
|
||||||
|
return await effect();
|
||||||
|
} catch (error) {
|
||||||
|
// Note we can't just push a rejected promise to #effects. This is because
|
||||||
|
// all rejections must be handled somewhere up in the call stack and since
|
||||||
|
// this function is synchronous, it is not handled anywhere in the call
|
||||||
|
// stack.
|
||||||
|
this.#error = error;
|
||||||
|
}
|
||||||
|
})()
|
||||||
|
);
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
get #effectsPromise(): Promise<unknown> {
|
||||||
|
if (this.#error) {
|
||||||
|
return Promise.reject(this.#error);
|
||||||
|
}
|
||||||
|
return Promise.all(this.#effects);
|
||||||
|
}
|
||||||
|
|
||||||
|
override then<TResult1 = T, TResult2 = never>(
|
||||||
|
onfulfilled?: (value: T) => TResult1 | PromiseLike<TResult1>,
|
||||||
|
onrejected?: (reason: any) => TResult2 | PromiseLike<TResult2>
|
||||||
|
): Operation<TResult1 | TResult2> {
|
||||||
|
return super.then(
|
||||||
|
value => {
|
||||||
|
this.#settled = true;
|
||||||
|
return this.#effectsPromise.then(() => {
|
||||||
|
if (!onfulfilled) {
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
return onfulfilled(value);
|
||||||
|
}, onrejected);
|
||||||
|
},
|
||||||
|
reason => {
|
||||||
|
this.#settled = true;
|
||||||
|
if (!onrejected) {
|
||||||
|
throw reason;
|
||||||
|
}
|
||||||
|
return onrejected(reason);
|
||||||
|
}
|
||||||
|
) as Operation<TResult1 | TResult2>;
|
||||||
|
}
|
||||||
|
}
|
@ -57,6 +57,7 @@ export * from './PredefinedNetworkConditions.js';
|
|||||||
export * from './Product.js';
|
export * from './Product.js';
|
||||||
export * from './Puppeteer.js';
|
export * from './Puppeteer.js';
|
||||||
export * from './PuppeteerViewport.js';
|
export * from './PuppeteerViewport.js';
|
||||||
|
export * from './Operation.js';
|
||||||
export * from './SecurityDetails.js';
|
export * from './SecurityDetails.js';
|
||||||
export * from './Target.js';
|
export * from './Target.js';
|
||||||
export * from './TargetManager.js';
|
export * from './TargetManager.js';
|
||||||
|
234
test/src/operation.spec.ts
Normal file
234
test/src/operation.spec.ts
Normal file
@ -0,0 +1,234 @@
|
|||||||
|
/**
|
||||||
|
* Copyright 2023 Google Inc. All rights reserved.
|
||||||
|
*
|
||||||
|
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
* you may not use this file except in compliance with the License.
|
||||||
|
* You may obtain a copy of the License at
|
||||||
|
*
|
||||||
|
* http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
*
|
||||||
|
* Unless required by applicable law or agreed to in writing, software
|
||||||
|
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
* See the License for the specific language governing permissions and
|
||||||
|
* limitations under the License.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import expect from 'expect';
|
||||||
|
import {Operation} from 'puppeteer-core/internal/common/Operation.js';
|
||||||
|
|
||||||
|
describe('Operation', () => {
|
||||||
|
it('should work', async () => {
|
||||||
|
const values: number[] = [];
|
||||||
|
|
||||||
|
await Operation.create(() => {
|
||||||
|
values.push(1);
|
||||||
|
})
|
||||||
|
.effect(() => {
|
||||||
|
values.push(2);
|
||||||
|
})
|
||||||
|
.effect(() => {
|
||||||
|
values.push(3);
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(values).toMatchObject([2, 3, 1]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should work with error on operation', async () => {
|
||||||
|
const values: number[] = [];
|
||||||
|
|
||||||
|
let errored: string | undefined;
|
||||||
|
try {
|
||||||
|
await Operation.create(() => {
|
||||||
|
throw new Error('test');
|
||||||
|
})
|
||||||
|
.effect(() => {
|
||||||
|
values.push(1);
|
||||||
|
})
|
||||||
|
.effect(() => {
|
||||||
|
values.push(2);
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
errored = (error as Error).message;
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(errored).toBe('test');
|
||||||
|
expect(values).toMatchObject([1, 2]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should work with error on effect', async () => {
|
||||||
|
const values: number[] = [];
|
||||||
|
|
||||||
|
let errored: string | undefined;
|
||||||
|
try {
|
||||||
|
await Operation.create(() => {
|
||||||
|
values.push(1);
|
||||||
|
})
|
||||||
|
.effect(() => {
|
||||||
|
throw new Error('test');
|
||||||
|
})
|
||||||
|
.effect(() => {
|
||||||
|
values.push(2);
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
errored = (error as Error).message;
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(errored).toBe('test');
|
||||||
|
expect(values).toMatchObject([2, 1]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should work with error on both operation and effect', async () => {
|
||||||
|
const values: number[] = [];
|
||||||
|
|
||||||
|
let errored: string | undefined;
|
||||||
|
try {
|
||||||
|
await Operation.create(() => {
|
||||||
|
throw new Error('test1');
|
||||||
|
})
|
||||||
|
.effect(() => {
|
||||||
|
throw new Error('test2');
|
||||||
|
})
|
||||||
|
.effect(() => {
|
||||||
|
values.push(1);
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
errored = (error as Error).message;
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(errored).toBe('test1');
|
||||||
|
expect(values).toMatchObject([1]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should work with delayed error on operation', async () => {
|
||||||
|
const values: number[] = [];
|
||||||
|
|
||||||
|
let errored: string | undefined;
|
||||||
|
try {
|
||||||
|
await Operation.create(() => {
|
||||||
|
return new Promise((_, reject) => {
|
||||||
|
return setTimeout(() => {
|
||||||
|
reject(new Error('test1'));
|
||||||
|
}, 10);
|
||||||
|
});
|
||||||
|
})
|
||||||
|
.effect(() => {
|
||||||
|
throw new Error('test2');
|
||||||
|
})
|
||||||
|
.effect(() => {
|
||||||
|
values.push(1);
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
errored = (error as Error).message;
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(errored).toBe('test1');
|
||||||
|
expect(values).toMatchObject([1]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should work with async error on effects', async () => {
|
||||||
|
const values: number[] = [];
|
||||||
|
|
||||||
|
let errored: string | undefined;
|
||||||
|
try {
|
||||||
|
await Operation.create(() => {
|
||||||
|
values.push(1);
|
||||||
|
})
|
||||||
|
.effect(async () => {
|
||||||
|
throw new Error('test');
|
||||||
|
})
|
||||||
|
.effect(() => {
|
||||||
|
values.push(2);
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
errored = (error as Error).message;
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(errored).toBe('test');
|
||||||
|
expect(values).toMatchObject([2, 1]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should work with then', async () => {
|
||||||
|
const values: number[] = [];
|
||||||
|
|
||||||
|
const operation = Operation.create(() => {
|
||||||
|
values.push(1);
|
||||||
|
})
|
||||||
|
.effect(() => {
|
||||||
|
values.push(2);
|
||||||
|
})
|
||||||
|
.effect(() => {
|
||||||
|
values.push(3);
|
||||||
|
})
|
||||||
|
.then(() => {
|
||||||
|
values.push(4);
|
||||||
|
});
|
||||||
|
await operation;
|
||||||
|
|
||||||
|
expect(operation).toBeInstanceOf(Operation);
|
||||||
|
expect(values).toMatchObject([2, 3, 1, 4]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should work with catch', async () => {
|
||||||
|
const values: number[] = [];
|
||||||
|
|
||||||
|
const operation = Operation.create(() => {
|
||||||
|
throw new Error('test');
|
||||||
|
})
|
||||||
|
.effect(() => {
|
||||||
|
values.push(1);
|
||||||
|
})
|
||||||
|
.effect(() => {
|
||||||
|
values.push(2);
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
values.push(3);
|
||||||
|
});
|
||||||
|
await operation;
|
||||||
|
|
||||||
|
expect(operation).toBeInstanceOf(Operation);
|
||||||
|
expect(values).toMatchObject([1, 2, 3]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should work with finally', async () => {
|
||||||
|
const values: number[] = [];
|
||||||
|
|
||||||
|
const operation = Operation.create(() => {
|
||||||
|
values.push(1);
|
||||||
|
})
|
||||||
|
.effect(() => {
|
||||||
|
values.push(2);
|
||||||
|
})
|
||||||
|
.effect(() => {
|
||||||
|
values.push(3);
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
values.push(4);
|
||||||
|
});
|
||||||
|
await operation;
|
||||||
|
|
||||||
|
expect(operation).toBeInstanceOf(Operation);
|
||||||
|
expect(values).toMatchObject([2, 3, 1, 4]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should throw when adding effects on on awaited operation', async () => {
|
||||||
|
const values: number[] = [];
|
||||||
|
|
||||||
|
const operation = Operation.create(() => {
|
||||||
|
values.push(1);
|
||||||
|
});
|
||||||
|
await operation;
|
||||||
|
|
||||||
|
expect(() => {
|
||||||
|
operation.effect(() => {
|
||||||
|
values.push(2);
|
||||||
|
});
|
||||||
|
}).toThrowError(
|
||||||
|
new Error(
|
||||||
|
'Attempted to add effect to a completed operation. Make sure effects are added synchronously after the operation is created.'
|
||||||
|
)
|
||||||
|
);
|
||||||
|
expect(operation).toBeInstanceOf(Operation);
|
||||||
|
expect(values).toMatchObject([1]);
|
||||||
|
});
|
||||||
|
});
|
Loading…
Reference in New Issue
Block a user