From 0cbaed1d2261853f91df058a7125ccf14e694866 Mon Sep 17 00:00:00 2001 From: Pavel Feldman Date: Sun, 7 Dec 2025 16:34:47 -0800 Subject: [PATCH] chore: add perform secrets, form tool --- docs/src/api/params.md | 7 ++- packages/playwright-client/types/types.d.ts | 11 +++- .../src/client/browserContext.ts | 8 +++ packages/playwright-core/src/client/types.ts | 5 +- .../playwright-core/src/protocol/validator.ts | 5 ++ .../src/server/agent/actionRunner.ts | 23 +++++-- .../src/server/agent/actions.ts | 10 ++- .../playwright-core/src/server/agent/agent.ts | 11 ++-- .../src/server/agent/context.ts | 37 +++++++++-- .../playwright-core/src/server/agent/tools.ts | 61 ++++++++++++++++--- packages/playwright-core/types/types.d.ts | 11 +++- packages/protocol/src/channels.d.ts | 9 +++ packages/protocol/src/protocol.yml | 3 + tests/{page => library}/agent-cache.json | 39 +++--------- tests/{page => library}/perform-task.spec.ts | 29 ++++++++- tests/library/playwright.config.ts | 6 -- 16 files changed, 200 insertions(+), 75 deletions(-) rename tests/{page => library}/agent-cache.json (53%) rename tests/{page => library}/perform-task.spec.ts (64%) diff --git a/docs/src/api/params.md b/docs/src/api/params.md index 8eb29d4df8f84..9220e1271390b 100644 --- a/docs/src/api/params.md +++ b/docs/src/api/params.md @@ -373,10 +373,11 @@ Emulates consistent window screen size available inside web page via `window.scr ## js-context-option-agent * langs: js - `agent` <[Object]> - - `provider` <[string]> LLM provider to use - - `model` <[string]> Model identifier within provider + - `provider` <[string]> LLM provider to use. + - `model` <[string]> Model identifier within provider. - `cacheFile` ?<[string]> Cache file to use/generate code for performed actions into. Cache is not used if not specified (default). - - `cacheMode` ?<['force'|'ignore'|'auto']> Cache control, defauls to 'auto' + - `cacheMode` ?<['force'|'ignore'|'auto']> Cache control, defaults to 'auto'. + - `secrets` ?<[Object]<[string], [string]>> Secrets to hide from the LLM. Agent settings for [`method: Page.perform`] and [`method: Page.extract`]. diff --git a/packages/playwright-client/types/types.d.ts b/packages/playwright-client/types/types.d.ts index cd99f7dd5b57f..77196a4080974 100644 --- a/packages/playwright-client/types/types.d.ts +++ b/packages/playwright-client/types/types.d.ts @@ -22087,12 +22087,12 @@ export interface BrowserContextOptions { */ agent?: { /** - * LLM provider to use + * LLM provider to use. */ provider: string; /** - * Model identifier within provider + * Model identifier within provider. */ model: string; @@ -22102,9 +22102,14 @@ export interface BrowserContextOptions { cacheFile?: string; /** - * Cache control, defauls to 'auto' + * Cache control, defaults to 'auto'. */ cacheMode?: 'force'|'ignore'|'auto'; + + /** + * Secrets to hide from the LLM. + */ + secrets?: { [key: string]: string; }; }; /** diff --git a/packages/playwright-core/src/client/browserContext.ts b/packages/playwright-core/src/client/browserContext.ts index 05178d2e7128c..83f83c81146e4 100644 --- a/packages/playwright-core/src/client/browserContext.ts +++ b/packages/playwright-core/src/client/browserContext.ts @@ -558,6 +558,7 @@ export async function prepareBrowserContextParams(platform: Platform, options: B network.validateHeaders(options.extraHTTPHeaders); const contextParams: channels.BrowserNewContextParams = { ...options, + agent: toAgentProtocol(options.agent), viewport: options.viewport === null ? undefined : options.viewport, noDefaultViewport: options.viewport === null, extraHTTPHeaders: options.extraHTTPHeaders ? headersObjectToArray(options.extraHTTPHeaders) : undefined, @@ -581,6 +582,13 @@ export async function prepareBrowserContextParams(platform: Platform, options: B return contextParams; } +function toAgentProtocol(agent?: BrowserContextOptions['agent']): channels.BrowserNewContextParams['agent'] { + if (!agent) + return undefined; + const secrets = agent.secrets ? Object.entries(agent.secrets).map(([name, value]) => ({ name, value })) : undefined; + return { ...agent, secrets }; +} + function toAcceptDownloadsProtocol(acceptDownloads?: boolean) { if (acceptDownloads === undefined) return undefined; diff --git a/packages/playwright-core/src/client/types.ts b/packages/playwright-core/src/client/types.ts index 48a2d71cf8953..265a1e8e5950d 100644 --- a/packages/playwright-core/src/client/types.ts +++ b/packages/playwright-core/src/client/types.ts @@ -58,7 +58,10 @@ export type ClientCertificate = { passphrase?: string; }; -export type BrowserContextOptions = Omit & { +export type AgentOptions = Omit, 'secrets'> & { secrets?: Record }; + +export type BrowserContextOptions = Omit & { + agent?: AgentOptions; viewport?: Size | null; extraHTTPHeaders?: Headers; logger?: Logger; diff --git a/packages/playwright-core/src/protocol/validator.ts b/packages/playwright-core/src/protocol/validator.ts index 0cf1d9f62bb86..d10e8f420eb6d 100644 --- a/packages/playwright-core/src/protocol/validator.ts +++ b/packages/playwright-core/src/protocol/validator.ts @@ -607,6 +607,7 @@ scheme.BrowserTypeLaunchPersistentContextParams = tObject({ model: tString, cacheFile: tOptional(tString), cacheMode: tOptional(tEnum(['ignore', 'force', 'auto'])), + secrets: tOptional(tArray(tType('NameValue'))), })), userDataDir: tString, slowMo: tOptional(tFloat), @@ -705,6 +706,7 @@ scheme.BrowserNewContextParams = tObject({ model: tString, cacheFile: tOptional(tString), cacheMode: tOptional(tEnum(['ignore', 'force', 'auto'])), + secrets: tOptional(tArray(tType('NameValue'))), })), proxy: tOptional(tObject({ server: tString, @@ -782,6 +784,7 @@ scheme.BrowserNewContextForReuseParams = tObject({ model: tString, cacheFile: tOptional(tString), cacheMode: tOptional(tEnum(['ignore', 'force', 'auto'])), + secrets: tOptional(tArray(tType('NameValue'))), })), proxy: tOptional(tObject({ server: tString, @@ -904,6 +907,7 @@ scheme.BrowserContextInitializer = tObject({ model: tString, cacheFile: tOptional(tString), cacheMode: tOptional(tEnum(['ignore', 'force', 'auto'])), + secrets: tOptional(tArray(tType('NameValue'))), })), }), }); @@ -2813,6 +2817,7 @@ scheme.AndroidDeviceLaunchBrowserParams = tObject({ model: tString, cacheFile: tOptional(tString), cacheMode: tOptional(tEnum(['ignore', 'force', 'auto'])), + secrets: tOptional(tArray(tType('NameValue'))), })), pkg: tOptional(tString), args: tOptional(tArray(tString)), diff --git a/packages/playwright-core/src/server/agent/actionRunner.ts b/packages/playwright-core/src/server/agent/actionRunner.ts index fdb9f81d23d1a..c1ee4d3227438 100644 --- a/packages/playwright-core/src/server/agent/actionRunner.ts +++ b/packages/playwright-core/src/server/agent/actionRunner.ts @@ -17,8 +17,9 @@ import type * as actions from './actions'; import type { Page } from '../page'; import type { Progress } from '../progress'; +import type { NameValue } from '@protocol/channels'; -export async function runAction(progress: Progress, page: Page, action: actions.Action) { +export async function runAction(progress: Progress, page: Page, action: actions.Action, secrets: NameValue[]) { const frame = page.mainFrame(); switch (action.method) { case 'click': @@ -31,21 +32,31 @@ export async function runAction(progress: Progress, page: Page, action: actions. await frame.hover(progress, action.selector, { ...action.options, ...strictTrue }); break; case 'selectOption': - await frame.selectOption(progress, action.selector, [], action.values.map(a => ({ value: a })), { ...strictTrue }); + await frame.selectOption(progress, action.selector, [], action.labels.map(a => ({ label: a })), { ...strictTrue }); break; case 'pressKey': await page.keyboard.press(progress, action.key); break; - case 'pressSequentially': - await frame.type(progress, action.selector, action.text, { ...strictTrue }); + case 'pressSequentially': { + const secret = secrets?.find(s => s.name === action.text)?.value ?? action.text; + await frame.type(progress, action.selector, secret, { ...strictTrue }); if (action.submit) await page.keyboard.press(progress, 'Enter'); break; - case 'fill': - await frame.fill(progress, action.selector, action.text, { ...strictTrue }); + } + case 'fill': { + const secret = secrets?.find(s => s.name === action.text)?.value ?? action.text; + await frame.fill(progress, action.selector, secret, { ...strictTrue }); if (action.submit) await page.keyboard.press(progress, 'Enter'); break; + } + case 'setChecked': + if (action.checked) + await frame.check(progress, action.selector, { ...strictTrue }); + else + await frame.uncheck(progress, action.selector, { ...strictTrue }); + break; } } diff --git a/packages/playwright-core/src/server/agent/actions.ts b/packages/playwright-core/src/server/agent/actions.ts index 030801c0a085c..1870dfbfe6a04 100644 --- a/packages/playwright-core/src/server/agent/actions.ts +++ b/packages/playwright-core/src/server/agent/actions.ts @@ -37,7 +37,7 @@ export type HoverAction = { export type SelectOptionAction = { method: 'selectOption'; selector: string; - values: string[]; + labels: string[]; }; export type PressAction = { @@ -59,4 +59,10 @@ export type FillAction = { submit?: boolean; }; -export type Action = ClickAction | DragAction | HoverAction | SelectOptionAction | PressAction | PressSequentiallyAction | FillAction; +export type SetChecked = { + method: 'setChecked'; + selector: string; + checked: boolean; +}; + +export type Action = ClickAction | DragAction | HoverAction | SelectOptionAction | PressAction | PressSequentiallyAction | FillAction | SetChecked; diff --git a/packages/playwright-core/src/server/agent/agent.ts b/packages/playwright-core/src/server/agent/agent.ts index fe8e2ff11b91b..4ab64460f8b37 100644 --- a/packages/playwright-core/src/server/agent/agent.ts +++ b/packages/playwright-core/src/server/agent/agent.ts @@ -85,26 +85,25 @@ type CachedActions = Record; const allCaches = new Map(); async function cachedPerform(context: Context, options: channels.PagePerformParams): Promise { - const agentSettings = context.page.browserContext._options.agent; - if (!agentSettings?.cacheFile || agentSettings.cacheMode === 'ignore') + if (!context.options?.cacheFile || context.options.cacheMode === 'ignore') return false; - const cache = await cachedActions(agentSettings.cacheFile); + const cache = await cachedActions(context.options.cacheFile); const cacheKey = options.key ?? options.task; const actions = cache[cacheKey]; if (!actions) { - if (agentSettings.cacheMode === 'force') + if (context.options.cacheMode === 'force') throw new Error(`No cached actions for key "${cacheKey}", but cache mode is set to "force"`); return false; } for (const action of actions) - await runAction(context.progress, context.page, action); + await runAction(context.progress, context.page, action, context.options.secrets ?? []); return true; } async function updateCache(context: Context, options: channels.PagePerformParams) { - const cacheFile = context.page.browserContext._options.agent?.cacheFile; + const cacheFile = context.options?.cacheFile; if (!cacheFile) return; const cache = await cachedActions(cacheFile); diff --git a/packages/playwright-core/src/server/agent/context.ts b/packages/playwright-core/src/server/agent/context.ts index a168cde5edbce..0a96a46c729b0 100644 --- a/packages/playwright-core/src/server/agent/context.ts +++ b/packages/playwright-core/src/server/agent/context.ts @@ -22,8 +22,12 @@ import type * as loopTypes from '@lowire/loop'; import type * as actions from './actions'; import type { Page } from '../page'; import type { Progress } from '../progress'; +import type { BrowserContextOptions } from '../types'; + +type AgentOptions = BrowserContextOptions['agent']; export class Context { + readonly options: AgentOptions; readonly progress: Progress; readonly page: Page; readonly actions: actions.Action[] = []; @@ -31,11 +35,20 @@ export class Context { constructor(progress: Progress, page: Page) { this.progress = progress; this.page = page; + this.options = page.browserContext._options.agent; + } + + async runActionAndWait(action: actions.Action) { + return await this.runActionsAndWait([action]); } - async runAction(action: actions.Action) { - await this.waitForCompletion(() => runAction(this.progress, this.page, action)); - this.actions.push(action); + async runActionsAndWait(action: actions.Action[]) { + await this.waitForCompletion(async () => { + for (const a of action) { + await runAction(this.progress, this.page, a, this.options?.secrets ?? []); + this.actions.push(a); + } + }); return await this.snapshotResult(); } @@ -71,7 +84,9 @@ export class Context { } async snapshotResult(): Promise { - const { full } = await this.page.snapshotForAI(this.progress); + let { full } = await this.page.snapshotForAI(this.progress); + full = this._redactText(full); + const text = [`# Page snapshot\n${full}`]; return { @@ -94,4 +109,18 @@ export class Context { } })); } + + private _redactText(text: string): string { + const secrets = this.options?.secrets; + if (!secrets) + return text; + + const redactText = (text: string) => { + for (const { name, value } of secrets) + text = text.replaceAll(value, `${name}`); + return text; + }; + + return redactText(text); + } } diff --git a/packages/playwright-core/src/server/agent/tools.ts b/packages/playwright-core/src/server/agent/tools.ts index b7db87a1d76b5..33125bc33b0b5 100644 --- a/packages/playwright-core/src/server/agent/tools.ts +++ b/packages/playwright-core/src/server/agent/tools.ts @@ -18,6 +18,7 @@ import { z } from '../../mcpBundle'; import type zod from 'zod'; import type * as loopTypes from '@lowire/loop'; +import type * as actions from './actions'; import type { Context } from './context'; type ToolSchema = Omit & { @@ -68,7 +69,7 @@ const click = defineTool({ handle: async (context, params) => { const [selector] = await context.refSelectors([params]); - return await context.runAction({ + return await context.runActionAndWait({ method: 'click', selector, options: { @@ -99,7 +100,7 @@ const drag = defineTool({ { ref: params.endRef, element: params.endElement }, ]); - return await context.runAction({ + return await context.runActionAndWait({ method: 'drag', sourceSelector, targetSelector @@ -121,7 +122,7 @@ const hover = defineTool({ handle: async (context, params) => { const [selector] = await context.refSelectors([params]); - return await context.runAction({ + return await context.runActionAndWait({ method: 'hover', selector, options: { @@ -145,10 +146,10 @@ const selectOption = defineTool({ handle: async (context, params) => { const [selector] = await context.refSelectors([params]); - return await context.runAction({ + return await context.runActionAndWait({ method: 'selectOption', selector, - values: params.values + labels: params.values }); }, }); @@ -164,7 +165,7 @@ const pressKey = defineTool({ }, handle: async (context, params) => { - return await context.runAction({ + return await context.runActionAndWait({ method: 'pressKey', key: params.key }); @@ -188,14 +189,14 @@ const type = defineTool({ handle: async (context, params) => { const [selector] = await context.refSelectors([params]); if (params.slowly) { - return await context.runAction({ + return await context.runActionAndWait({ method: 'pressSequentially', selector, text: params.text, submit: params.submit, }); } else { - return await context.runAction({ + return await context.runActionAndWait({ method: 'fill', selector, text: params.text, @@ -205,6 +206,49 @@ const type = defineTool({ }, }); +const fillForm = defineTool({ + schema: { + name: 'browser_fill_form', + title: 'Fill form', + description: 'Fill multiple form fields', + inputSchema: z.object({ + fields: z.array(z.object({ + name: z.string().describe('Human-readable field name'), + type: z.enum(['textbox', 'checkbox', 'radio', 'combobox', 'slider']).describe('Type of the field'), + ref: z.string().describe('Exact target field reference from the page snapshot'), + value: z.string().describe('Value to fill in the field. If the field is a checkbox, the value should be `true` or `false`. If the field is a combobox, the value should be the text of the option.'), + })).describe('Fields to fill in'), + }), + }, + + handle: async (context, params) => { + const actions: actions.Action[] = []; + for (const field of params.fields) { + const [selector] = await context.refSelectors([{ ref: field.ref, element: field.name }]); + if (field.type === 'textbox' || field.type === 'slider') { + actions.push({ + method: 'fill', + selector, + text: field.value, + }); + } else if (field.type === 'checkbox' || field.type === 'radio') { + actions.push({ + method: 'setChecked', + selector, + checked: field.value === 'true', + }); + } else if (field.type === 'combobox') { + actions.push({ + method: 'selectOption', + selector, + labels: [field.value], + }); + } + } + return await context.runActionsAndWait(actions); + }, +}); + export default [ snapshot, click, @@ -213,4 +257,5 @@ export default [ selectOption, pressKey, type, + fillForm, ]; diff --git a/packages/playwright-core/types/types.d.ts b/packages/playwright-core/types/types.d.ts index cd99f7dd5b57f..77196a4080974 100644 --- a/packages/playwright-core/types/types.d.ts +++ b/packages/playwright-core/types/types.d.ts @@ -22087,12 +22087,12 @@ export interface BrowserContextOptions { */ agent?: { /** - * LLM provider to use + * LLM provider to use. */ provider: string; /** - * Model identifier within provider + * Model identifier within provider. */ model: string; @@ -22102,9 +22102,14 @@ export interface BrowserContextOptions { cacheFile?: string; /** - * Cache control, defauls to 'auto' + * Cache control, defaults to 'auto'. */ cacheMode?: 'force'|'ignore'|'auto'; + + /** + * Secrets to hide from the LLM. + */ + secrets?: { [key: string]: string; }; }; /** diff --git a/packages/protocol/src/channels.d.ts b/packages/protocol/src/channels.d.ts index e7244adb0953d..1c74085748d7f 100644 --- a/packages/protocol/src/channels.d.ts +++ b/packages/protocol/src/channels.d.ts @@ -1013,6 +1013,7 @@ export type BrowserTypeLaunchPersistentContextParams = { model: string, cacheFile?: string, cacheMode?: 'ignore' | 'force' | 'auto', + secrets?: NameValue[], }, userDataDir: string, slowMo?: number, @@ -1101,6 +1102,7 @@ export type BrowserTypeLaunchPersistentContextOptions = { model: string, cacheFile?: string, cacheMode?: 'ignore' | 'force' | 'auto', + secrets?: NameValue[], }, slowMo?: number, }; @@ -1228,6 +1230,7 @@ export type BrowserNewContextParams = { model: string, cacheFile?: string, cacheMode?: 'ignore' | 'force' | 'auto', + secrets?: NameValue[], }, proxy?: { server: string, @@ -1302,6 +1305,7 @@ export type BrowserNewContextOptions = { model: string, cacheFile?: string, cacheMode?: 'ignore' | 'force' | 'auto', + secrets?: NameValue[], }, proxy?: { server: string, @@ -1379,6 +1383,7 @@ export type BrowserNewContextForReuseParams = { model: string, cacheFile?: string, cacheMode?: 'ignore' | 'force' | 'auto', + secrets?: NameValue[], }, proxy?: { server: string, @@ -1453,6 +1458,7 @@ export type BrowserNewContextForReuseOptions = { model: string, cacheFile?: string, cacheMode?: 'ignore' | 'force' | 'auto', + secrets?: NameValue[], }, proxy?: { server: string, @@ -1594,6 +1600,7 @@ export type BrowserContextInitializer = { model: string, cacheFile?: string, cacheMode?: 'ignore' | 'force' | 'auto', + secrets?: NameValue[], }, }, }; @@ -4906,6 +4913,7 @@ export type AndroidDeviceLaunchBrowserParams = { model: string, cacheFile?: string, cacheMode?: 'ignore' | 'force' | 'auto', + secrets?: NameValue[], }, pkg?: string, args?: string[], @@ -4978,6 +4986,7 @@ export type AndroidDeviceLaunchBrowserOptions = { model: string, cacheFile?: string, cacheMode?: 'ignore' | 'force' | 'auto', + secrets?: NameValue[], }, pkg?: string, args?: string[], diff --git a/packages/protocol/src/protocol.yml b/packages/protocol/src/protocol.yml index f36d34abbfa8c..07c3dfad140e9 100644 --- a/packages/protocol/src/protocol.yml +++ b/packages/protocol/src/protocol.yml @@ -601,6 +601,9 @@ ContextOptions: - ignore - force - auto + secrets: + type: array? + items: NameValue LocalUtils: type: interface diff --git a/tests/page/agent-cache.json b/tests/library/agent-cache.json similarity index 53% rename from tests/page/agent-cache.json rename to tests/library/agent-cache.json index b3ac1e1e08e9d..3b7fde719e3aa 100644 --- a/tests/page/agent-cache.json +++ b/tests/library/agent-cache.json @@ -1,36 +1,4 @@ { - "Fill out the form with the following details:\nName: John Doe\nAddress: 123 Main St, Anytown, XYZ state\nZip Code: 12345\nEmail: john@doe.me": [ - { - "method": "fill", - "selector": "internal:role=textbox[name=\"Full Name *\"i]", - "text": "John Doe" - }, - { - "method": "fill", - "selector": "internal:role=textbox[name=\"Email Address *\"i]", - "text": "john@doe.me" - }, - { - "method": "fill", - "selector": "internal:role=textbox[name=\"Street Address *\"i]", - "text": "123 Main St" - }, - { - "method": "fill", - "selector": "internal:role=textbox[name=\"City *\"i]", - "text": "Anytown" - }, - { - "method": "fill", - "selector": "internal:role=textbox[name=\"State/Province *\"i]", - "text": "XYZ" - }, - { - "method": "fill", - "selector": "internal:role=textbox[name=\"ZIP/Postal Code *\"i]", - "text": "12345" - } - ], "Fill out the form with the following details:\nName: John Smith\nAddress: 1045 La Avenida St, Mountain View, CA 94043\nEmail: john.smith@at-microsoft.com": [ { "method": "fill", @@ -62,5 +30,12 @@ "selector": "internal:role=textbox[name=\"ZIP/Postal Code *\"i]", "text": "94043" } + ], + "Enter x-secret-email into the email field": [ + { + "method": "fill", + "selector": "internal:role=textbox[name=\"Email Address\"i]", + "text": "x-secret-email" + } ] } \ No newline at end of file diff --git a/tests/page/perform-task.spec.ts b/tests/library/perform-task.spec.ts similarity index 64% rename from tests/page/perform-task.spec.ts rename to tests/library/perform-task.spec.ts index a28df7d411c58..87f8e82ad2ee5 100644 --- a/tests/page/perform-task.spec.ts +++ b/tests/library/perform-task.spec.ts @@ -14,9 +14,28 @@ * limitations under the License. */ -import { test, expect } from './pageTest'; +import path from 'path'; import z from 'zod'; +import { browserTest as test, expect } from '../config/browserTest'; + +test.use({ + contextOptions: async ({ contextOptions }, use, testInfo) => { + await use({ + ...contextOptions, + agent: { + provider: 'github', + model: 'claude-sonnet-4.5', + cacheFile: path.join(testInfo.project.testDir, 'agent-cache.json'), + cacheMode: process.env.CI ? 'force' : 'auto', + secrets: { + 'x-secret-email': 'secret-email@at-microsoft.com', + } + }, + }); + } +}); + test('page.perform', async ({ page, server }) => { await page.goto(server.PREFIX + '/evals/fill-form.html'); await page.perform('Fill out the form with the following details:\n' + @@ -33,6 +52,14 @@ test('page.perform', async ({ page, server }) => { `); }); +test('page.perform secret', async ({ page, server }) => { + await page.setContent(''); + await page.perform('Enter x-secret-email into the email field'); + await expect(page.locator('body')).toMatchAriaSnapshot(` + - textbox "Email Address": secret-email@at-microsoft.com + `); +}); + test.skip('extract task', async ({ page }) => { await page.goto('https://demo.playwright.dev/todomvc'); await page.perform('Add "Buy groceries" todo'); diff --git a/tests/library/playwright.config.ts b/tests/library/playwright.config.ts index 1a958c93c5f90..5339d79701422 100644 --- a/tests/library/playwright.config.ts +++ b/tests/library/playwright.config.ts @@ -154,12 +154,6 @@ for (const browserName of browserNames) { testDir: path.join(testDir, 'page'), ...projectTemplate, }; - pageProject.use.agent = { - provider: 'github', - model: 'claude-sonnet-4.5', - cacheFile: path.join(testDir, 'page', 'agent-cache.json'), - cacheMode: process.env.CI ? 'force' : 'auto', - }; config.projects.push(pageProject); }