From 4650ba4b4f8f26ad1f1a86c287b12938f2368f62 Mon Sep 17 00:00:00 2001 From: Dmitry Gozman Date: Sat, 11 Jul 2026 15:05:33 +0100 Subject: [PATCH] chore(recorder): turn preconditionSelector into an expect signal (#41727) --- .../injected/src/recorder/pollingRecorder.ts | 6 +-- packages/injected/src/recorder/recorder.ts | 11 +++--- packages/isomorphic/codegen/actions.d.ts | 9 ++++- packages/isomorphic/codegen/csharp.ts | 11 ++++-- packages/isomorphic/codegen/java.ts | 7 +++- packages/isomorphic/codegen/javascript.ts | 6 ++- packages/isomorphic/codegen/jsonl.ts | 9 ++++- packages/isomorphic/codegen/language.ts | 37 ++++++++----------- packages/isomorphic/codegen/python.ts | 7 +++- packages/isomorphic/codegen/types.ts | 4 +- .../src/server/debugController.ts | 2 +- .../playwright-core/src/server/recorder.ts | 23 ++++++------ .../src/server/recorder/recorderApp.ts | 2 +- tests/library/debug-controller.spec.ts | 29 +++++++++++++++ tests/library/inspector/cli-codegen-2.spec.ts | 10 ++--- 15 files changed, 109 insertions(+), 64 deletions(-) diff --git a/packages/injected/src/recorder/pollingRecorder.ts b/packages/injected/src/recorder/pollingRecorder.ts index 5649dd852b1a7..c3da732fde8c0 100644 --- a/packages/injected/src/recorder/pollingRecorder.ts +++ b/packages/injected/src/recorder/pollingRecorder.ts @@ -22,7 +22,7 @@ import type * as actions from '@isomorphic/codegen/actions'; import type { ElementInfo, Mode, OverlayState, UIState } from '@recorder/recorderTypes'; interface Embedder { - __pw_recorderPerformAction(action: actions.PerformOnRecordAction): Promise; + __pw_recorderPerformAction(action: actions.PerformOnRecordAction, preconditionSelector?: string): Promise; __pw_recorderRecordAction(action: actions.Action): Promise; __pw_recorderState(): Promise; __pw_recorderElementPicked(element: { selector: string, ariaSnapshot?: string }): Promise; @@ -76,8 +76,8 @@ export class PollingRecorder implements RecorderDelegate { this._pollRecorderModeTimer = this._recorder.injectedScript.utils.builtins.setTimeout(() => this._pollRecorderMode(), pollPeriod); } - async performAction(action: actions.PerformOnRecordAction) { - await this._embedder.__pw_recorderPerformAction(action); + async performAction(action: actions.PerformOnRecordAction, preconditionSelector?: string) { + await this._embedder.__pw_recorderPerformAction(action, preconditionSelector); } async recordAction(action: actions.Action): Promise { diff --git a/packages/injected/src/recorder/recorder.ts b/packages/injected/src/recorder/recorder.ts index 9005d70bc403d..91f146ea1ab61 100644 --- a/packages/injected/src/recorder/recorder.ts +++ b/packages/injected/src/recorder/recorder.ts @@ -35,7 +35,7 @@ const HighlightColors = { }; export interface RecorderDelegate { - performAction?(action: actions.PerformOnRecordAction): Promise; + performAction?(action: actions.PerformOnRecordAction, preconditionSelector?: string): Promise; recordAction?(action: actions.Action): Promise; elementPicked?(elementInfo: ElementInfo): Promise; setMode?(mode: Mode): Promise; @@ -1698,13 +1698,14 @@ export class Recorder { async performAction(action: actions.PerformOnRecordAction) { const previousSnapshot = this._lastActionAutoexpectSnapshot; this._lastActionAutoexpectSnapshot = this._captureAutoExpectSnapshot(); + let preconditionSelector: string | undefined; if (!isAssertAction(action) && this._lastActionAutoexpectSnapshot) { const element = this.injectedScript.utils.findNewElement(previousSnapshot?.root, this._lastActionAutoexpectSnapshot?.root); - action.preconditionSelector = element ? this.injectedScript.generateSelector(element, { testIdAttributeName: this.state.testIdAttributeName }).selector : undefined; - if (action.preconditionSelector === action.selector) - action.preconditionSelector = undefined; + preconditionSelector = element ? this.injectedScript.generateSelector(element, { testIdAttributeName: this.state.testIdAttributeName }).selector : undefined; + if (preconditionSelector === action.selector) + preconditionSelector = undefined; } - await this._delegate.performAction?.(action).catch(() => {}); + await this._delegate.performAction?.(action, preconditionSelector).catch(() => {}); } async recordAction(action: actions.Action) { diff --git a/packages/isomorphic/codegen/actions.d.ts b/packages/isomorphic/codegen/actions.d.ts index e8092bdf0fa44..9fa7203a23975 100644 --- a/packages/isomorphic/codegen/actions.d.ts +++ b/packages/isomorphic/codegen/actions.d.ts @@ -38,7 +38,6 @@ export type ActionBase = { name: ActionName, signals: Signal[], ariaSnapshot?: string, - preconditionSelector?: string, }; export type ActionWithSelector = ActionBase & { @@ -156,7 +155,13 @@ export type DialogSignal = BaseSignal & { dialogAlias: string, }; -export type Signal = NavigationSignal | PopupSignal | DownloadSignal | DialogSignal; +// An element that appeared since the previous action, asserted before this action runs. +export type ExpectSignal = BaseSignal & { + name: 'expect', + selector: string, +}; + +export type Signal = NavigationSignal | PopupSignal | DownloadSignal | DialogSignal | ExpectSignal; export type FrameDescription = { pageGuid: string; diff --git a/packages/isomorphic/codegen/csharp.ts b/packages/isomorphic/codegen/csharp.ts index e62265936533b..5df1321f6089f 100644 --- a/packages/isomorphic/codegen/csharp.ts +++ b/packages/isomorphic/codegen/csharp.ts @@ -16,7 +16,7 @@ import { asLocator } from '../locatorGenerators'; import { escapeWithQuotes } from '../stringUtils'; -import { sanitizeDeviceOptions, toClickOptionsForSourceCode, toKeyboardModifiers, toSignalMap } from './language'; +import { expectSignalAction, sanitizeDeviceOptions, toClickOptionsForSourceCode, toKeyboardModifiers, toSignalMap } from './language'; import { deviceDescriptors } from '../deviceDescriptors'; import type { Language, LanguageGenerator, LanguageGeneratorOptions } from './types'; @@ -51,14 +51,14 @@ export class CSharpLanguageGenerator implements LanguageGenerator { this._mode = mode; } - generateAction(actionInContext: actions.ActionInContext): string { - const action = this._generateActionInner(actionInContext); + generateAction(actionInContext: actions.ActionInContext, options: LanguageGeneratorOptions): string { + const action = this._generateActionInner(actionInContext, options); if (action) return action; return ''; } - _generateActionInner(actionInContext: actions.ActionInContext): string { + _generateActionInner(actionInContext: actions.ActionInContext, options: LanguageGeneratorOptions): string { const action = actionInContext.action; if (this._mode !== 'library' && (action.name === 'openPage' || action.name === 'closePage')) return ''; @@ -101,6 +101,9 @@ export class CSharpLanguageGenerator implements LanguageGenerator { for (const line of lines) formatter.add(line); + if (options.generateExpectSignal && signals.expect) + formatter.add(this.generateAction(expectSignalAction(actionInContext, signals.expect), options)); + return formatter.format(); } diff --git a/packages/isomorphic/codegen/java.ts b/packages/isomorphic/codegen/java.ts index 99398a213788c..bbb119b6e2bb2 100644 --- a/packages/isomorphic/codegen/java.ts +++ b/packages/isomorphic/codegen/java.ts @@ -16,7 +16,7 @@ import { asLocator } from '../locatorGenerators'; import { escapeWithQuotes } from '../stringUtils'; -import { toClickOptionsForSourceCode, toKeyboardModifiers, toSignalMap } from './language'; +import { expectSignalAction, toClickOptionsForSourceCode, toKeyboardModifiers, toSignalMap } from './language'; import { deviceDescriptors } from '../deviceDescriptors'; import { JavaScriptFormatter } from './javascript'; @@ -47,7 +47,7 @@ export class JavaLanguageGenerator implements LanguageGenerator { this._mode = mode; } - generateAction(actionInContext: actions.ActionInContext): string { + generateAction(actionInContext: actions.ActionInContext, options: LanguageGeneratorOptions): string { const action = actionInContext.action; const pageAlias = actionInContext.frame.pageAlias; const offset = this._mode === 'junit' ? 4 : 6; @@ -89,6 +89,9 @@ export class JavaLanguageGenerator implements LanguageGenerator { formatter.add(code); + if (options.generateExpectSignal && signals.expect) + formatter.add(this.generateAction(expectSignalAction(actionInContext, signals.expect), options)); + return formatter.format(); } diff --git a/packages/isomorphic/codegen/javascript.ts b/packages/isomorphic/codegen/javascript.ts index a0dc4e7ca3f66..9237df55711b2 100644 --- a/packages/isomorphic/codegen/javascript.ts +++ b/packages/isomorphic/codegen/javascript.ts @@ -16,7 +16,7 @@ import { asLocator } from '../locatorGenerators'; import { escapeWithQuotes, formatObject, formatObjectOrVoid } from '../stringUtils'; -import { sanitizeDeviceOptions, toClickOptionsForSourceCode, toKeyboardModifiers, toSignalMap } from './language'; +import { expectSignalAction, sanitizeDeviceOptions, toClickOptionsForSourceCode, toKeyboardModifiers, toSignalMap } from './language'; import { deviceDescriptors } from '../deviceDescriptors'; import type { Language, LanguageGenerator, LanguageGeneratorOptions } from './types'; @@ -36,7 +36,7 @@ export class JavaScriptLanguageGenerator implements LanguageGenerator { this._isTest = isTest; } - generateAction(actionInContext: actions.ActionInContext): string { + generateAction(actionInContext: actions.ActionInContext, options: LanguageGeneratorOptions): string { const action = actionInContext.action; if (this._isTest && (action.name === 'openPage' || action.name === 'closePage')) return ''; @@ -72,6 +72,8 @@ export class JavaScriptLanguageGenerator implements LanguageGenerator { formatter.add(`const ${signals.popup.popupAlias} = await ${signals.popup.popupAlias}Promise;`); if (signals.download) formatter.add(`const download${signals.download.downloadAlias} = await download${signals.download.downloadAlias}Promise;`); + if (options.generateExpectSignal && signals.expect) + formatter.add(this.generateAction(expectSignalAction(actionInContext, signals.expect), options)); return formatter.format(); } diff --git a/packages/isomorphic/codegen/jsonl.ts b/packages/isomorphic/codegen/jsonl.ts index c6c44ccad5e90..2f6aecd81c391 100644 --- a/packages/isomorphic/codegen/jsonl.ts +++ b/packages/isomorphic/codegen/jsonl.ts @@ -15,6 +15,7 @@ */ import { asLocator } from '../locatorGenerators'; +import { expectSignalAction, toSignalMap } from './language'; import type { Language, LanguageGenerator, LanguageGeneratorOptions } from './types'; import type * as actions from './actions'; @@ -25,7 +26,7 @@ export class JsonlLanguageGenerator implements LanguageGenerator { name = 'JSONL'; highlighter = 'javascript' as Language; - generateAction(actionInContext: actions.ActionInContext): string { + generateAction(actionInContext: actions.ActionInContext, options: LanguageGeneratorOptions): string { const locator = (actionInContext.action as any).selector ? JSON.parse(asLocator('jsonl', (actionInContext.action as any).selector)) : undefined; const entry = { ...actionInContext.action, @@ -33,7 +34,11 @@ export class JsonlLanguageGenerator implements LanguageGenerator { locator, ariaSnapshot: undefined, }; - return JSON.stringify(entry); + const lines = [JSON.stringify(entry)]; + const expect = toSignalMap(actionInContext.action).expect; + if (options.generateExpectSignal && expect) + lines.push(this.generateAction(expectSignalAction(actionInContext, expect), options)); + return lines.join('\n'); } generateHeader(options: LanguageGeneratorOptions): string { diff --git a/packages/isomorphic/codegen/language.ts b/packages/isomorphic/codegen/language.ts index 413ee9f0864de..8ea2c5fa97c6b 100644 --- a/packages/isomorphic/codegen/language.ts +++ b/packages/isomorphic/codegen/language.ts @@ -22,31 +22,22 @@ import type * as actions from './actions'; export function generateCode(actions: actions.ActionInContext[], languageGenerator: LanguageGenerator, options: LanguageGeneratorOptions) { const header = languageGenerator.generateHeader(options); const footer = languageGenerator.generateFooter(options.saveStorage); - const actionTexts = actions.map(a => generateActionText(languageGenerator, a, !!options.generateAutoExpect)).filter(Boolean) as string[]; + const actionTexts = actions.map(a => languageGenerator.generateAction(a, options)).filter(Boolean); const text = [header, ...actionTexts, footer].join('\n'); return { header, footer, actionTexts, text }; } -function generateActionText(generator: LanguageGenerator, action: actions.ActionInContext, generateAutoExpect: boolean): string | undefined { - let text = generator.generateAction(action); - if (!text) - return; - if (generateAutoExpect && action.action.preconditionSelector) { - const expectAction: actions.ActionInContext = { - frame: action.frame, - startTime: action.startTime, - endTime: action.startTime, - action: { - name: 'assertVisible', - selector: action.action.preconditionSelector, - signals: [], - }, - }; - const expectText = generator.generateAction(expectAction); - if (expectText) - text = expectText + '\n\n' + text; - } - return text; +export function expectSignalAction(actionInContext: actions.ActionInContext, signal: actions.ExpectSignal): actions.ActionInContext { + return { + frame: actionInContext.frame, + startTime: actionInContext.startTime, + endTime: actionInContext.startTime, + action: { + name: 'assertVisible', + selector: signal.selector, + signals: [], + }, + }; } export function sanitizeDeviceOptions(device: any, options: BrowserContextOptions): BrowserContextOptions { @@ -63,6 +54,7 @@ export function toSignalMap(action: actions.Action) { let popup: actions.PopupSignal | undefined; let download: actions.DownloadSignal | undefined; let dialog: actions.DialogSignal | undefined; + let expect: actions.ExpectSignal | undefined; for (const signal of action.signals) { if (signal.name === 'popup') popup = signal; @@ -70,11 +62,14 @@ export function toSignalMap(action: actions.Action) { download = signal; else if (signal.name === 'dialog') dialog = signal; + else if (signal.name === 'expect') + expect = signal; } return { popup, download, dialog, + expect, }; } diff --git a/packages/isomorphic/codegen/python.ts b/packages/isomorphic/codegen/python.ts index a16a09556119f..c80039d7fc1c8 100644 --- a/packages/isomorphic/codegen/python.ts +++ b/packages/isomorphic/codegen/python.ts @@ -16,7 +16,7 @@ import { asLocator } from '../locatorGenerators'; import { escapeWithQuotes, toSnakeCase } from '../stringUtils'; -import { sanitizeDeviceOptions, toClickOptionsForSourceCode, toKeyboardModifiers, toSignalMap } from './language'; +import { expectSignalAction, sanitizeDeviceOptions, toClickOptionsForSourceCode, toKeyboardModifiers, toSignalMap } from './language'; import { deviceDescriptors } from '../deviceDescriptors'; import type { Language, LanguageGenerator, LanguageGeneratorOptions } from './types'; @@ -43,7 +43,7 @@ export class PythonLanguageGenerator implements LanguageGenerator { this._asyncPrefix = isAsync ? 'async ' : ''; } - generateAction(actionInContext: actions.ActionInContext): string { + generateAction(actionInContext: actions.ActionInContext, options: LanguageGeneratorOptions): string { const action = actionInContext.action; if (this._isPyTest && (action.name === 'openPage' || action.name === 'closePage')) return ''; @@ -82,6 +82,9 @@ export class PythonLanguageGenerator implements LanguageGenerator { formatter.add(code); + if (options.generateExpectSignal && signals.expect) + formatter.add(this.generateAction(expectSignalAction(actionInContext, signals.expect), options)); + return formatter.format(); } diff --git a/packages/isomorphic/codegen/types.ts b/packages/isomorphic/codegen/types.ts index 105590eac779e..000f1e49cce5c 100644 --- a/packages/isomorphic/codegen/types.ts +++ b/packages/isomorphic/codegen/types.ts @@ -36,7 +36,7 @@ export type LanguageGeneratorOptions = { contextOptions: BrowserContextOptions; deviceName?: string; saveStorage?: string; - generateAutoExpect?: boolean; + generateExpectSignal?: boolean; }; export interface LanguageGenerator { @@ -45,6 +45,6 @@ export interface LanguageGenerator { name: string; highlighter: Language; generateHeader(options: LanguageGeneratorOptions): string; - generateAction(actionInContext: actions.ActionInContext): string; + generateAction(actionInContext: actions.ActionInContext, options: LanguageGeneratorOptions): string; generateFooter(saveStorage: string | undefined): string; } diff --git a/packages/playwright-core/src/server/debugController.ts b/packages/playwright-core/src/server/debugController.ts index ff755bfdd2347..3706ddb4ee6e9 100644 --- a/packages/playwright-core/src/server/debugController.ts +++ b/packages/playwright-core/src/server/debugController.ts @@ -193,7 +193,7 @@ function wireListeners(recorder: Recorder, debugController: DebugController) { browserName: 'chromium', launchOptions: {}, contextOptions: {}, - generateAutoExpect: debugController._generateAutoExpect, + generateExpectSignal: debugController._generateAutoExpect, }); debugController.emit(DebugController.Events.SourceChanged, { text, header, footer, actions: actionTexts }); }; diff --git a/packages/playwright-core/src/server/recorder.ts b/packages/playwright-core/src/server/recorder.ts index a6537f64a34eb..81eecfbfadd3f 100644 --- a/packages/playwright-core/src/server/recorder.ts +++ b/packages/playwright-core/src/server/recorder.ts @@ -229,7 +229,7 @@ export class Recorder extends EventEmitter implements Instrume // Input actions that potentially lead to navigation are intercepted on the page and are // performed by the Playwright. await this._context.exposeBinding(progress, '__pw_recorderPerformAction', - (source: BindingSource, action: actions.PerformOnRecordAction) => this._performAction(progress, source.frame, action)); + (source: BindingSource, action: actions.PerformOnRecordAction, preconditionSelector?: string) => this._performAction(progress, source.frame, action, preconditionSelector)); // Other non-essential actions are simply being recorded. await this._context.exposeBinding(progress, '__pw_recorderRecordAction', @@ -559,14 +559,9 @@ export class Recorder extends EventEmitter implements Instrume return this._params.testIdAttributeName || this._context.selectors().testIdAttributeName() || 'data-testid'; } - private async _appendContextToAction(progress: Progress, frame: Frame, action: actions.Action): Promise { - const framePath = await generateFrameSelector(progress, frame); - if (framePath.length) { - if ('selector' in action) - action.selector = buildFullSelector(framePath, action.selector); - if (action.preconditionSelector) - action.preconditionSelector = buildFullSelector(framePath, action.preconditionSelector); - } + private _appendContextToAction(frame: Frame, action: actions.Action, framePath: string[]): actions.ActionInContext { + if (framePath.length && 'selector' in action) + action.selector = buildFullSelector(framePath, action.selector); const actionInContext: actions.ActionInContext = { frame: this._describeMainFrame(frame._page), action, @@ -575,8 +570,11 @@ export class Recorder extends EventEmitter implements Instrume return actionInContext; } - private async _performAction(progress: Progress, frame: Frame, action: actions.PerformOnRecordAction) { - const actionInContext = await this._appendContextToAction(progress, frame, action); + private async _performAction(progress: Progress, frame: Frame, action: actions.PerformOnRecordAction, preconditionSelector?: string) { + const framePath = await generateFrameSelector(progress, frame); + if (preconditionSelector) + this._signalProcessor.signal(this._pageAliases.get(frame._page)!, frame, { name: 'expect', selector: buildFullSelector(framePath, preconditionSelector) }); + const actionInContext = this._appendContextToAction(frame, action, framePath); this._signalProcessor.addAction(actionInContext); try { if (actionInContext.action.name !== 'openPage' && actionInContext.action.name !== 'closePage') @@ -587,7 +585,8 @@ export class Recorder extends EventEmitter implements Instrume } private async _recordAction(progress: Progress, frame: Frame, action: actions.Action) { - const actionInContext = await this._appendContextToAction(progress, frame, action); + const framePath = await generateFrameSelector(progress, frame); + const actionInContext = this._appendContextToAction(frame, action, framePath); this._signalProcessor.addAction(actionInContext); } diff --git a/packages/playwright-core/src/server/recorder/recorderApp.ts b/packages/playwright-core/src/server/recorder/recorderApp.ts index c692d1cc361fc..09d5619478854 100644 --- a/packages/playwright-core/src/server/recorder/recorderApp.ts +++ b/packages/playwright-core/src/server/recorder/recorderApp.ts @@ -147,7 +147,7 @@ export class RecorderApp { } }, setAutoExpect: async (params: { autoExpect: boolean }) => { - this._languageGeneratorOptions.generateAutoExpect = params.autoExpect; + this._languageGeneratorOptions.generateExpectSignal = params.autoExpect; this._updateActions(); }, setMode: async (params: { mode: Mode }) => { diff --git a/tests/library/debug-controller.spec.ts b/tests/library/debug-controller.spec.ts index 3224f816315e6..0bdb25cc24658 100644 --- a/tests/library/debug-controller.spec.ts +++ b/tests/library/debug-controller.spec.ts @@ -208,6 +208,35 @@ test('test', async ({ page }) => { expect(events).toHaveLength(length); }); +test('should record expect signal', async ({ backend, connectedBrowser }) => { + const events = []; + backend.on('sourceChanged', event => events.push(event)); + + await backend.setRecorderMode({ mode: 'recording', generateAutoExpect: true }, undefined); + + const context = await connectedBrowser.newContextForReuse(); + const [page] = context.pages(); + + // Clicking "Show" reveals "Saved", which becomes the precondition of the next action. + await page.setContent(` + + + + `); + + await page.getByRole('button', { name: 'Show' }).click(); + // A click stalls for 200ms to detect a double click, and the next click cancels a pending one. + await expect.poll(() => events[events.length - 1]?.actions.length).toBe(2); + await page.getByRole('button', { name: 'Other' }).click(); + + // The signal is attached to the "Show" click, so the assertion renders right after it. + await expect.poll(() => events[events.length - 1]?.actions).toEqual([ + ` await page.goto('about:blank');`, + ` await page.getByRole('button', { name: 'Show' }).click();\n await expect(page.getByRole('button', { name: 'Saved' })).toBeVisible();`, + ` await page.getByRole('button', { name: 'Other' }).click();`, + ]); +}); + test('should record custom data-testid', async ({ backend, connectedBrowser }) => { // This test emulates "record at cursor" functionality // with custom test id attribute in the config. diff --git a/tests/library/inspector/cli-codegen-2.spec.ts b/tests/library/inspector/cli-codegen-2.spec.ts index 67ce3951537ae..38336ac4d8a95 100644 --- a/tests/library/inspector/cli-codegen-2.spec.ts +++ b/tests/library/inspector/cli-codegen-2.spec.ts @@ -579,28 +579,28 @@ await page.Locator("#textarea").FillAsync(\"Hello'\\"\`\\nWorld\");`); ]); expect.soft(sources.get('Playwright Test')!.text).toContain(` + await page.getByRole('button', { name: 'one' }).click(); await expect(page.getByRole('heading', { name: 'new header' })).toBeVisible(); - await page.getByRole('button', { name: 'two' }).click();`); expect.soft(sources.get('Python')!.text).toContain(` + page.get_by_role("button", name="one").click() expect(page.get_by_role("heading", name="new header")).to_be_visible() - page.get_by_role("button", name="two").click()`); expect.soft(sources.get('Python Async')!.text).toContain(` + await page.get_by_role("button", name="one").click() await expect(page.get_by_role("heading", name="new header")).to_be_visible() - await page.get_by_role("button", name="two").click()`); expect.soft(sources.get('Java')!.text).toContain(` + page.getByRole(AriaRole.BUTTON, new Page.GetByRoleOptions().setName("one")).click(); assertThat(page.getByRole(AriaRole.HEADING, new Page.GetByRoleOptions().setName("new header"))).isVisible(); - page.getByRole(AriaRole.BUTTON, new Page.GetByRoleOptions().setName("two")).click();`); expect.soft(sources.get('C#')!.text).toContain(` +await page.GetByRole(AriaRole.Button, new() { Name = "one" }).ClickAsync(); await Expect(page.GetByRole(AriaRole.Heading, new() { Name = "new header" })).ToBeVisibleAsync(); - await page.GetByRole(AriaRole.Button, new() { Name = "two" }).ClickAsync();`); }); });