chore: make various progress instances "strict" (#36349)

This commit is contained in:
Dmitry Gozman 2025-06-20 10:47:36 +01:00 committed by GitHub
parent 84c69edbb8
commit c0da193660
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
8 changed files with 97 additions and 88 deletions

View File

@ -32,9 +32,9 @@ import { chromiumSwitches } from '../chromium/chromiumSwitches';
import { CRBrowser } from '../chromium/crBrowser';
import { removeFolders } from '../utils/fileUtils';
import { helper } from '../helper';
import { SdkObject, serverSideCallMetadata } from '../instrumentation';
import { CallMetadata, SdkObject } from '../instrumentation';
import { gracefullyCloseSet } from '../utils/processLauncher';
import { ProgressController } from '../progress';
import { Progress, ProgressController } from '../progress';
import { registry } from '../registry';
import type { BrowserOptions, BrowserProcess } from '../browser';
@ -122,6 +122,7 @@ export class AndroidDevice extends SdkObject {
this.model = model;
this.serial = backend.serial;
this._options = options;
this.logName = 'browser';
}
static async create(android: Android, backend: DeviceBackend, options: channels.AndroidDevicesOptions): Promise<AndroidDevice> {
@ -258,18 +259,21 @@ export class AndroidDevice extends SdkObject {
this.emit(AndroidDevice.Events.Close);
}
async launchBrowser(pkg: string = 'com.android.chrome', options: channels.AndroidDeviceLaunchBrowserParams): Promise<BrowserContext> {
debug('pw:android')('Force-stopping', pkg);
await this._backend.runCommand(`shell:am force-stop ${pkg}`);
const socketName = isUnderTest() ? 'webview_devtools_remote_playwright_test' : ('playwright_' + createGuid() + '_devtools_remote');
const commandLine = this._defaultArgs(options, socketName).join(' ');
debug('pw:android')('Starting', pkg, commandLine);
// encode commandLine to base64 to avoid issues (bash encoding) with special characters
await this._backend.runCommand(`shell:echo "${Buffer.from(commandLine).toString('base64')}" | base64 -d > /data/local/tmp/chrome-command-line`);
await this._backend.runCommand(`shell:am start -a android.intent.action.VIEW -d about:blank ${pkg}`);
const browserContext = await this._connectToBrowser(socketName, options);
await this._backend.runCommand(`shell:rm /data/local/tmp/chrome-command-line`);
return browserContext;
async launchBrowser(metadata: CallMetadata, pkg: string = 'com.android.chrome', options: channels.AndroidDeviceLaunchBrowserParams): Promise<BrowserContext> {
const controller = new ProgressController(metadata, this, 'strict');
return controller.run(async progress => {
debug('pw:android')('Force-stopping', pkg);
await this._backend.runCommand(`shell:am force-stop ${pkg}`);
const socketName = isUnderTest() ? 'webview_devtools_remote_playwright_test' : ('playwright_' + createGuid() + '_devtools_remote');
const commandLine = this._defaultArgs(options, socketName).join(' ');
debug('pw:android')('Starting', pkg, commandLine);
// encode commandLine to base64 to avoid issues (bash encoding) with special characters
await progress.race(this._backend.runCommand(`shell:echo "${Buffer.from(commandLine).toString('base64')}" | base64 -d > /data/local/tmp/chrome-command-line`));
await progress.race(this._backend.runCommand(`shell:am start -a android.intent.action.VIEW -d about:blank ${pkg}`));
const browserContext = await this._connectToBrowser(progress, socketName, options);
await progress.race(this._backend.runCommand(`shell:rm /data/local/tmp/chrome-command-line`));
return browserContext;
});
}
private _defaultArgs(options: channels.AndroidDeviceLaunchBrowserParams, socketName: string): string[] {
@ -301,25 +305,30 @@ export class AndroidDevice extends SdkObject {
return chromeArguments;
}
async connectToWebView(socketName: string): Promise<BrowserContext> {
const webView = this._webViews.get(socketName);
if (!webView)
throw new Error('WebView has been closed');
return await this._connectToBrowser(socketName);
async connectToWebView(metadata: CallMetadata, socketName: string): Promise<BrowserContext> {
const controller = new ProgressController(metadata, this, 'strict');
return controller.run(async progress => {
const webView = this._webViews.get(socketName);
if (!webView)
throw new Error('WebView has been closed');
return await this._connectToBrowser(progress, socketName);
});
}
private async _connectToBrowser(socketName: string, options: types.BrowserContextOptions = {}): Promise<BrowserContext> {
const socket = await this._waitForLocalAbstract(socketName);
private async _connectToBrowser(progress: Progress, socketName: string, options: types.BrowserContextOptions = {}): Promise<BrowserContext> {
const socket = await progress.race(this._waitForLocalAbstract(socketName));
const androidBrowser = new AndroidBrowser(this, socket);
await androidBrowser._init();
progress.cleanupWhenAborted(() => androidBrowser.close());
await progress.race(androidBrowser._init());
this._browserConnections.add(androidBrowser);
const artifactsDir = await fs.promises.mkdtemp(ARTIFACTS_FOLDER);
const artifactsDir = await progress.race(fs.promises.mkdtemp(ARTIFACTS_FOLDER));
const cleanupArtifactsDir = async () => {
const errors = (await removeFolders([artifactsDir])).filter(Boolean);
for (let i = 0; i < (errors || []).length; ++i)
debug('pw:android')(`exception while removing ${artifactsDir}: ${errors[i]}`);
};
progress.cleanupWhenAborted(cleanupArtifactsDir);
gracefullyCloseSet.add(cleanupArtifactsDir);
socket.on('close', async () => {
gracefullyCloseSet.delete(cleanupArtifactsDir);
@ -341,12 +350,9 @@ export class AndroidDevice extends SdkObject {
};
validateBrowserContextOptions(options, browserOptions);
const browser = await CRBrowser.connect(this.attribution.playwright, androidBrowser, browserOptions);
const controller = new ProgressController(serverSideCallMetadata(), this);
const browser = await progress.race(CRBrowser.connect(this.attribution.playwright, androidBrowser, browserOptions));
const defaultContext = browser._defaultContext!;
await controller.run(async progress => {
await defaultContext._loadDefaultContextAsIs(progress);
});
await defaultContext._loadDefaultContextAsIs(progress);
return defaultContext;
}

View File

@ -191,12 +191,12 @@ export abstract class BrowserContext extends SdkObject {
}
async resetForReuse(metadata: CallMetadata, params: channels.BrowserNewContextForReuseParams | null) {
const controller = new ProgressController(metadata, this);
const controller = new ProgressController(metadata, this, 'strict');
return controller.run(progress => this.resetForReuseImpl(progress, params));
}
async resetForReuseImpl(progress: Progress, params: channels.BrowserNewContextForReuseParams | null) {
await this.tracing.resetForReuse();
await progress.race(this.tracing.resetForReuse());
if (params) {
for (const key of paramsThatAllowContextReuse)
@ -219,18 +219,22 @@ export abstract class BrowserContext extends SdkObject {
await page?.mainFrame().gotoImpl(progress, 'about:blank', {});
await this._resetStorage(progress);
await this.clock.resetForReuse();
// TODO: following can be optimized to not perform noops.
if (this._options.permissions)
await this.grantPermissions(this._options.permissions);
else
await this.clearPermissions();
await this.setExtraHTTPHeaders(this._options.extraHTTPHeaders || []);
await this.setGeolocation(this._options.geolocation);
await this.setOffline(!!this._options.offline);
await this.setUserAgent(this._options.userAgent);
await this.clearCache();
await this._resetCookies();
const resetOptions = async () => {
await this.clock.resetForReuse();
// TODO: following can be optimized to not perform noops.
if (this._options.permissions)
await this.grantPermissions(this._options.permissions);
else
await this.clearPermissions();
await this.setExtraHTTPHeaders(this._options.extraHTTPHeaders || []);
await this.setGeolocation(this._options.geolocation);
await this.setOffline(!!this._options.offline);
await this.setUserAgent(this._options.userAgent);
await this.clearCache();
await this._resetCookies();
};
await progress.race(resetOptions());
await page?.resetForReuse(progress);
}

View File

@ -42,10 +42,11 @@ export class VideoRecorder {
if (!options.outputFile.endsWith('.webm'))
throw new Error('File must have .webm extension');
const controller = new ProgressController(serverSideCallMetadata(), page);
const controller = new ProgressController(serverSideCallMetadata(), page, 'strict');
controller.setLogName('browser');
return await controller.run(async progress => {
const recorder = new VideoRecorder(page, ffmpegPath, progress);
progress.cleanupWhenAborted(() => recorder.stop());
await recorder._launch(options);
return recorder;
});

View File

@ -160,10 +160,10 @@ export class AndroidDeviceDispatcher extends Dispatcher<AndroidDevice, channels.
await this._object.push(params.file, params.path, params.mode);
}
async launchBrowser(params: channels.AndroidDeviceLaunchBrowserParams): Promise<channels.AndroidDeviceLaunchBrowserResult> {
async launchBrowser(params: channels.AndroidDeviceLaunchBrowserParams, metadata: CallMetadata): Promise<channels.AndroidDeviceLaunchBrowserResult> {
if (this.parentScope()._denyLaunch)
throw new Error(`Launching more browsers is not allowed.`);
const context = await this._object.launchBrowser(params.pkg, params);
const context = await this._object.launchBrowser(metadata, params.pkg, params);
return { context: BrowserContextDispatcher.from(this, context) };
}
@ -171,10 +171,10 @@ export class AndroidDeviceDispatcher extends Dispatcher<AndroidDevice, channels.
await this._object.close();
}
async connectToWebView(params: channels.AndroidDeviceConnectToWebViewParams): Promise<channels.AndroidDeviceConnectToWebViewResult> {
async connectToWebView(params: channels.AndroidDeviceConnectToWebViewParams, metadata: CallMetadata): Promise<channels.AndroidDeviceConnectToWebViewResult> {
if (this.parentScope()._denyLaunch)
throw new Error(`Launching more browsers is not allowed.`);
return { context: BrowserContextDispatcher.from(this, await this._object.connectToWebView(params.socketName)) };
return { context: BrowserContextDispatcher.from(this, await this._object.connectToWebView(metadata, params.socketName)) };
}
}

View File

@ -1370,9 +1370,9 @@ export class Frame extends SdkObject {
}
async ariaSnapshot(metadata: CallMetadata, selector: string, options: { forAI?: boolean } & types.TimeoutOptions): Promise<string> {
const controller = new ProgressController(metadata, this);
const controller = new ProgressController(metadata, this, 'strict');
return controller.run(async progress => {
return await this._retryWithProgressIfNotConnected(progress, selector, true /* strict */, true /* performActionPreChecks */, handle => handle.ariaSnapshot(options));
return await this._retryWithProgressIfNotConnected(progress, selector, true /* strict */, true /* performActionPreChecks */, handle => progress.race(handle.ariaSnapshot(options)));
}, options.timeout);
}
@ -1391,7 +1391,7 @@ export class Frame extends SdkObject {
const start = timeout > 0 ? monotonicTime() : 0;
// Step 1: perform locator handlers checkpoint with a specified timeout.
await (new ProgressController(metadata, this)).run(async progress => {
await (new ProgressController(metadata, this, 'strict')).run(async progress => {
progress.log(`${renderTitleForCall(metadata)}${timeout ? ` with timeout ${timeout}ms` : ''}`);
if (selector)
progress.log(`waiting for ${this._asLocator(selector)}`);
@ -1402,7 +1402,7 @@ export class Frame extends SdkObject {
// Supports the case of `expect(locator).toBeVisible({ timeout: 1 })`
// that should succeed when the locator is already visible.
try {
const resultOneShot = await (new ProgressController(metadata, this)).run(async progress => {
const resultOneShot = await (new ProgressController(metadata, this, 'strict')).run(async progress => {
return await this._expectInternal(progress, selector, options, lastIntermediateResult);
});
if (resultOneShot.matches !== options.isNot)
@ -1420,7 +1420,7 @@ export class Frame extends SdkObject {
return { matches: options.isNot, log: compressCallLog(metadata.log), timedOut: true, received: lastIntermediateResult.received };
// Step 3: auto-retry expect with increasing timeouts. Bounded by the total remaining time.
return await (new ProgressController(metadata, this)).run(async progress => {
return await (new ProgressController(metadata, this, 'strict')).run(async progress => {
return await this.retryWithProgressAndTimeouts(progress, [100, 250, 500, 1000], async continuePolling => {
await this._page.performActionPreChecks(progress);
const { matches, received } = await this._expectInternal(progress, selector, options, lastIntermediateResult);
@ -1448,16 +1448,14 @@ export class Frame extends SdkObject {
}
private async _expectInternal(progress: Progress, selector: string | undefined, options: FrameExpectParams, lastIntermediateResult: { received?: any, isSet: boolean }) {
const selectorInFrame = selector ? await this.selectors.resolveFrameForSelector(selector, { strict: true }) : undefined;
progress.throwIfAborted();
const selectorInFrame = selector ? await progress.race(this.selectors.resolveFrameForSelector(selector, { strict: true })) : undefined;
const { frame, info } = selectorInFrame || { frame: this, info: undefined };
const world = options.expression === 'to.have.property' ? 'main' : (info?.world ?? 'utility');
const context = await frame._context(world);
const injected = await context.injectedScript();
progress.throwIfAborted();
const context = await progress.race(frame._context(world));
const injected = await progress.race(context.injectedScript());
const { log, matches, received, missingReceived } = await injected.evaluate(async (injected, { info, options, callId }) => {
const { log, matches, received, missingReceived } = await progress.race(injected.evaluate(async (injected, { info, options, callId }) => {
const elements = info ? injected.querySelectorAll(info.parsed, document) : [];
if (callId)
injected.markTargetElements(new Set(elements), callId);
@ -1470,7 +1468,7 @@ export class Frame extends SdkObject {
else if (elements.length)
log = ` locator resolved to ${injected.previewNode(elements[0])}`;
return { log, ...await injected.expect(elements[0], options, elements) };
}, { info, options, callId: progress.metadata.id });
}, { info, options, callId: progress.metadata.id }));
if (log)
progress.log(log);

View File

@ -812,10 +812,13 @@ export class Page extends SdkObject {
this._isServerSideOnly = true;
}
async snapshotForAI(metadata: CallMetadata): Promise<string> {
this.lastSnapshotFrameIds = [];
const snapshot = await snapshotFrameForAI(metadata, this.mainFrame(), 0, this.lastSnapshotFrameIds);
return snapshot.join('\n');
snapshotForAI(metadata: CallMetadata): Promise<string> {
const controller = new ProgressController(metadata, this, 'strict');
return controller.run(async progress => {
this.lastSnapshotFrameIds = [];
const snapshot = await snapshotFrameForAI(progress, this.mainFrame(), 0, this.lastSnapshotFrameIds);
return snapshot.join('\n');
});
}
}
@ -991,29 +994,26 @@ class FrameThrottler {
}
}
async function snapshotFrameForAI(metadata: CallMetadata, frame: frames.Frame, frameOrdinal: number, frameIds: string[]): Promise<string[]> {
async function snapshotFrameForAI(progress: Progress, frame: frames.Frame, frameOrdinal: number, frameIds: string[]): Promise<string[]> {
// Only await the topmost navigations, inner frames will be empty when racing.
const controller = new ProgressController(metadata, frame);
const snapshot = await controller.run(progress => {
return frame.retryWithProgressAndTimeouts(progress, [1000, 2000, 4000, 8000], async continuePolling => {
try {
const context = await frame._utilityContext();
const injectedScript = await context.injectedScript();
const snapshotOrRetry = await injectedScript.evaluate((injected, refPrefix) => {
const node = injected.document.body;
if (!node)
return true;
return injected.ariaSnapshot(node, { forAI: true, refPrefix });
}, frameOrdinal ? 'f' + frameOrdinal : '');
if (snapshotOrRetry === true)
return continuePolling;
return snapshotOrRetry;
} catch (e) {
if (isAbortError(e) || isSessionClosedError(e) || js.isJavaScriptErrorInEvaluate(e))
throw e;
const snapshot = await frame.retryWithProgressAndTimeouts(progress, [1000, 2000, 4000, 8000], async continuePolling => {
try {
const context = await progress.race(frame._utilityContext());
const injectedScript = await progress.race(context.injectedScript());
const snapshotOrRetry = await progress.race(injectedScript.evaluate((injected, refPrefix) => {
const node = injected.document.body;
if (!node)
return true;
return injected.ariaSnapshot(node, { forAI: true, refPrefix });
}, frameOrdinal ? 'f' + frameOrdinal : ''));
if (snapshotOrRetry === true)
return continuePolling;
}
});
return snapshotOrRetry;
} catch (e) {
if (isAbortError(e) || isSessionClosedError(e) || js.isJavaScriptErrorInEvaluate(e))
throw e;
return continuePolling;
}
});
const lines = snapshot.split('\n');
@ -1029,7 +1029,7 @@ async function snapshotFrameForAI(metadata: CallMetadata, frame: frames.Frame, f
const ref = match[2];
const frameSelector = `aria-ref=${ref} >> internal:control=enter-frame`;
const frameBodySelector = `${frameSelector} >> body`;
const child = await frame.selectors.resolveFrameForSelector(frameBodySelector, { strict: true });
const child = await progress.race(frame.selectors.resolveFrameForSelector(frameBodySelector, { strict: true }));
if (!child) {
result.push(line);
continue;
@ -1037,7 +1037,7 @@ async function snapshotFrameForAI(metadata: CallMetadata, frame: frames.Frame, f
const frameOrdinal = frameIds.length + 1;
frameIds.push(child.frame._id);
try {
const childSnapshot = await snapshotFrameForAI(metadata, child.frame, frameOrdinal, frameIds);
const childSnapshot = await snapshotFrameForAI(progress, child.frame, frameOrdinal, frameIds);
result.push(line + ':', ...childSnapshot.map(l => leadingSpace + ' ' + l));
} catch {
result.push(line);

View File

@ -121,7 +121,7 @@ export class RecorderApp extends EventEmitter implements IRecorderApp {
timeout: 0,
}
});
const controller = new ProgressController(serverSideCallMetadata(), context._browser);
const controller = new ProgressController(serverSideCallMetadata(), context._browser, 'strict');
await controller.run(async progress => {
await context._browser._defaultContext!._loadDefaultContextAsIs(progress);
});

View File

@ -180,7 +180,7 @@ export async function openTraceViewerApp(url: string, browserName: string, optio
},
});
const controller = new ProgressController(serverSideCallMetadata(), context._browser);
const controller = new ProgressController(serverSideCallMetadata(), context._browser, 'strict');
await controller.run(async progress => {
await context._browser._defaultContext!._loadDefaultContextAsIs(progress);
});