diff --git a/packages/puppeteer-core/src/common/Operation.ts b/packages/puppeteer-core/src/common/Operation.ts new file mode 100644 index 00000000000..3314641471f --- /dev/null +++ b/packages/puppeteer-core/src/common/Operation.ts @@ -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 effects + * 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 extends Promise { + /** + * @internal + */ + static create(fn: () => Awaitable, delay = 0): Operation { + return new Operation((resolve, reject) => { + setTimeout(async () => { + try { + resolve(await fn()); + } catch (error) { + reject(error); + } + }, delay); + }); + } + + #settled = false; + #effects: Array> = []; + #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): 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 { + if (this.#error) { + return Promise.reject(this.#error); + } + return Promise.all(this.#effects); + } + + override then( + onfulfilled?: (value: T) => TResult1 | PromiseLike, + onrejected?: (reason: any) => TResult2 | PromiseLike + ): Operation { + 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; + } +} diff --git a/packages/puppeteer-core/src/common/common.ts b/packages/puppeteer-core/src/common/common.ts index 99be593141e..e0cb10cbbc2 100644 --- a/packages/puppeteer-core/src/common/common.ts +++ b/packages/puppeteer-core/src/common/common.ts @@ -57,6 +57,7 @@ export * from './PredefinedNetworkConditions.js'; export * from './Product.js'; export * from './Puppeteer.js'; export * from './PuppeteerViewport.js'; +export * from './Operation.js'; export * from './SecurityDetails.js'; export * from './Target.js'; export * from './TargetManager.js'; diff --git a/test/src/operation.spec.ts b/test/src/operation.spec.ts new file mode 100644 index 00000000000..b59e673009c --- /dev/null +++ b/test/src/operation.spec.ts @@ -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]); + }); +});