|
| 1 | +import type { SerializedLocator } from './locators' |
| 2 | +import { getBrowserState, getWorkerState } from '../utils' |
| 3 | + |
| 4 | +export interface ActionOptions { |
| 5 | + timeout?: number |
| 6 | +} |
| 7 | + |
| 8 | +// an array gets the options appended; a factory receives them and builds the full argument list |
| 9 | +type ActionArguments = unknown[] | ((options: ActionOptions | undefined) => Promise<unknown[]>) |
| 10 | + |
| 11 | +/** explicit option, then the provider default, then the remaining task time */ |
| 12 | +export function resolveActionTimeout(options?: ActionOptions): number | undefined { |
| 13 | + if (options?.timeout != null) { |
| 14 | + return options.timeout |
| 15 | + } |
| 16 | + if (getWorkerState().config.browser.providerOptions.actionTimeout != null) { |
| 17 | + return undefined |
| 18 | + } |
| 19 | + return getBrowserState().runner._deadline?.derive() |
| 20 | +} |
| 21 | + |
| 22 | +/** |
| 23 | + * @deprecated the timeout is derived by the action itself; pass the options through unchanged |
| 24 | + */ |
| 25 | +export function processTimeoutOptions<T extends { timeout?: number }>(options?: T): T | undefined { |
| 26 | + const timeout = resolveActionTimeout(options) |
| 27 | + if (timeout == null) { |
| 28 | + return options |
| 29 | + } |
| 30 | + return { ...options, timeout } as T |
| 31 | +} |
| 32 | + |
| 33 | +/** |
| 34 | + * A browser command that the test awaits. The action derives its own timeout |
| 35 | + * from the running task, so it fails with a descriptive error before the task does. |
| 36 | + */ |
| 37 | +class Action<T = void> implements Promise<T> { |
| 38 | + public readonly [Symbol.toStringTag] = 'Action' |
| 39 | + readonly #command: string |
| 40 | + readonly #args: ActionArguments |
| 41 | + readonly #options: ActionOptions | undefined |
| 42 | + readonly #errorSource: Error |
| 43 | + #promise: Promise<T> | undefined |
| 44 | + #awaited = false |
| 45 | + |
| 46 | + constructor( |
| 47 | + command: string, |
| 48 | + args: ActionArguments, |
| 49 | + options: ActionOptions | undefined, |
| 50 | + errorSource?: Error, |
| 51 | + ) { |
| 52 | + this.#command = command |
| 53 | + this.#args = args |
| 54 | + this.#options = options |
| 55 | + this.#errorSource = errorSource ?? new Error('STACK_TRACE_ERROR') |
| 56 | + const test = getWorkerState().current |
| 57 | + if (errorSource || !test || test.type !== 'test') { |
| 58 | + this.#promise = this.#run() |
| 59 | + return |
| 60 | + } |
| 61 | + test.onFinished ??= [] |
| 62 | + test.onFinished.push(() => { |
| 63 | + if (!this.#awaited) { |
| 64 | + const error = new Error( |
| 65 | + `The call was not awaited. This method is asynchronous and must be awaited; otherwise, the call will not start to avoid unhandled rejections.`, |
| 66 | + ) |
| 67 | + error.stack = this.#errorSource.stack?.replace(this.#errorSource.message, error.message) |
| 68 | + throw error |
| 69 | + } |
| 70 | + }) |
| 71 | + } |
| 72 | + |
| 73 | + async #run(): Promise<T> { |
| 74 | + const timeout = resolveActionTimeout(this.#options) |
| 75 | + const options = timeout == null ? this.#options : { ...this.#options, timeout } |
| 76 | + const args = typeof this.#args === 'function' |
| 77 | + ? await this.#args(options) |
| 78 | + : [...this.#args, options] |
| 79 | + const promise = getBrowserState().commands.triggerCommand<T>( |
| 80 | + this.#command, |
| 81 | + args, |
| 82 | + this.#errorSource, |
| 83 | + ) |
| 84 | + const deadline = getBrowserState().runner._deadline |
| 85 | + return deadline && timeout != null |
| 86 | + ? deadline.track(this.#command.slice('__vitest_'.length), promise, timeout, this.#errorSource) |
| 87 | + : promise |
| 88 | + } |
| 89 | + |
| 90 | + // the command starts only when awaited, so an unawaited action cannot reject unhandled |
| 91 | + #start(): Promise<T> { |
| 92 | + this.#awaited = true |
| 93 | + return this.#promise ??= this.#run() |
| 94 | + } |
| 95 | + |
| 96 | + then<R1 = T, R2 = never>( |
| 97 | + onFulfilled?: ((value: T) => R1 | PromiseLike<R1>) | null, |
| 98 | + onRejected?: ((reason: any) => R2 | PromiseLike<R2>) | null, |
| 99 | + ): Promise<R1 | R2> { |
| 100 | + return this.#start().then(onFulfilled, onRejected) |
| 101 | + } |
| 102 | + |
| 103 | + catch<R = never>(onRejected?: ((reason: any) => R | PromiseLike<R>) | null): Promise<T | R> { |
| 104 | + return this.#start().catch(onRejected) |
| 105 | + } |
| 106 | + |
| 107 | + finally(onFinally?: (() => void) | null): Promise<T> { |
| 108 | + return this.#start().finally(onFinally) |
| 109 | + } |
| 110 | +} |
| 111 | + |
| 112 | +export class LocatorAction<T = void> extends Action<T> { |
| 113 | + constructor( |
| 114 | + target: SerializedLocator, |
| 115 | + command: string, |
| 116 | + args: unknown[], |
| 117 | + options?: ActionOptions, |
| 118 | + errorSource?: Error, |
| 119 | + ) { |
| 120 | + super(command, [target, ...args], options, errorSource) |
| 121 | + } |
| 122 | +} |
| 123 | + |
| 124 | +export class UploadAction extends Action { |
| 125 | + constructor( |
| 126 | + target: SerializedLocator, |
| 127 | + files: string | string[] | File | File[], |
| 128 | + options?: ActionOptions, |
| 129 | + errorSource?: Error, |
| 130 | + ) { |
| 131 | + super('__vitest_upload', async options => [target, await readFiles(files), options], options, errorSource) |
| 132 | + } |
| 133 | +} |
| 134 | + |
| 135 | +export class ScreenshotAction<T> extends Action<T> { |
| 136 | + constructor( |
| 137 | + name: string, |
| 138 | + options: ActionOptions, |
| 139 | + serialize: () => Promise<Record<string, unknown>>, |
| 140 | + ) { |
| 141 | + super('__vitest_screenshot', async options => [name, { ...options, ...await serialize() }], options) |
| 142 | + } |
| 143 | +} |
| 144 | + |
| 145 | +function readFiles(files: string | string[] | File | File[]): Promise<(string | { name: string; mimeType: string; base64: string })[]> { |
| 146 | + return Promise.all((Array.isArray(files) ? files : [files]).map(async (file) => { |
| 147 | + if (typeof file === 'string') { |
| 148 | + return file |
| 149 | + } |
| 150 | + const bas64String = await new Promise<string>((resolve, reject) => { |
| 151 | + const reader = new FileReader() |
| 152 | + reader.onload = () => resolve(reader.result as string) |
| 153 | + reader.onerror = () => reject(new Error(`Failed to read file: ${file.name}`)) |
| 154 | + reader.readAsDataURL(file) |
| 155 | + }) |
| 156 | + |
| 157 | + return { |
| 158 | + name: file.name, |
| 159 | + mimeType: file.type, |
| 160 | + // strip prefix `data:[<media-type>][;base64],` |
| 161 | + base64: bas64String.slice(bas64String.indexOf(',') + 1), |
| 162 | + } |
| 163 | + })) |
| 164 | +} |
0 commit comments