diff --git a/.claude/skills/playwright-dev/SKILL.md b/.claude/skills/playwright-dev/SKILL.md index 25cf694ca1993..e169d33c7c489 100644 --- a/.claude/skills/playwright-dev/SKILL.md +++ b/.claude/skills/playwright-dev/SKILL.md @@ -11,6 +11,7 @@ description: Explains how to develop Playwright - add APIs, MCP tools, CLI comma - [Adding and Modifying APIs](api.md) — define API docs, implement client/server, add tests - [MCP Tools and CLI Commands](mcp-dev.md) — add MCP tools, CLI commands, config options - [Vendoring Dependencies](vendor.md) — bundle third-party npm packages into playwright-core or playwright +- [Uploading Fixes to GitHub](github.md) — branch naming, commit format, pushing fixes for issues ## Build - Assume watch is running and everything is up to date. diff --git a/.claude/skills/playwright-dev/github.md b/.claude/skills/playwright-dev/github.md new file mode 100644 index 0000000000000..ff59e1426172f --- /dev/null +++ b/.claude/skills/playwright-dev/github.md @@ -0,0 +1,56 @@ +# Uploading a Fix for a GitHub Issue + +## Branch naming + +Create a branch named after the issue number: + +``` +git checkout -b fix-39562 +``` + +## Committing changes + +Use conventional commit format with a scope: + +- `fix(proxy): description` — bug fixes +- `feat(locator): description` — new features +- `chore(cli): description` — maintenance, refactoring, tests + +The commit body must be a single line: `Fixes: https://github.com/microsoft/playwright/issues/39562` + +Stage only the files related to the fix. Do not use `git add -A` or `git add .`. + +``` +git add src/server/proxy.ts tests/proxy.spec.ts +git commit -m "$(cat <<'EOF' +fix(proxy): handle SOCKS proxy authentication + +Fixes: https://github.com/microsoft/playwright/issues/39562 +EOF +)" +``` + +## Pushing + +Push the branch to origin: + +``` +git push origin fix-39562 +``` + +## Full example + +For issue https://github.com/microsoft/playwright/issues/39562: + +```bash +git checkout -b fix-39562 +# ... make changes ... +git add +git commit -m "$(cat <<'EOF' +fix(proxy): handle SOCKS proxy authentication + +Fixes: https://github.com/microsoft/playwright/issues/39562 +EOF +)" +git push origin fix-39562 +``` diff --git a/docs/src/api/class-cdpsession.md b/docs/src/api/class-cdpsession.md index 8efa8e1da71ad..6573d6386095c 100644 --- a/docs/src/api/class-cdpsession.md +++ b/docs/src/api/class-cdpsession.md @@ -77,7 +77,7 @@ Emitted when the session is closed, either because the target was closed or `ses * since: v1.59 * langs: js - argument: <[Object]> - - `name` <[string]> CDP event name. + - `method` <[string]> CDP event name. - `params` ?<[Object]> CDP event parameters. Emitted for every CDP event received from the session. Allows subscribing to all CDP events at once without knowing diff --git a/docs/src/test-agents-js.md b/docs/src/test-agents-js.md index 727fcea3bd42a..6ef76dd03d144 100644 --- a/docs/src/test-agents-js.md +++ b/docs/src/test-agents-js.md @@ -206,7 +206,7 @@ When the test fails, the healer agent: **Output** -* A passing test, or a skipped test if the healer believes the that functionality is broken. +* A passing test, or a skipped test if the healer believes that functionality is broken. ## Artifacts and Conventions diff --git a/docs/src/trace-viewer.md b/docs/src/trace-viewer.md index ed5a855e5c22b..2192b98d60c61 100644 --- a/docs/src/trace-viewer.md +++ b/docs/src/trace-viewer.md @@ -686,7 +686,9 @@ public class WithTestNameAttribute : BeforeAfterTestAttribute ## Trace Viewer features ### Actions -In the Actions tab you can see what locator was used for every action and how long each one took to run. Hover over each action of your test and visually see the change in the DOM snapshot. Go back and forward in time and click an action to inspect and debug. Use the Before and After tabs to visually see what happened before and after the action. +In the Actions tab you can see what locator was used for every action and how long each one took to run. Use the **Filter actions** search field at the top of the list to filter the action hierarchy by text; only actions whose title matches the search (and their parent actions) are shown. Clear the filter to show all actions again; expanded nodes are preserved when the filter is removed. + +Hover over each action of your test and visually see the change in the DOM snapshot. Go back and forward in time and click an action to inspect and debug. Use the Before and After tabs to visually see what happened before and after the action. actions tab in trace viewer diff --git a/packages/devtools/src/devtools.tsx b/packages/devtools/src/devtools.tsx index 20f97e5f04a4f..96e5c2f4eed80 100644 --- a/packages/devtools/src/devtools.tsx +++ b/packages/devtools/src/devtools.tsx @@ -40,7 +40,7 @@ const BUTTONS = ['left', 'middle', 'right'] as const; export const DevTools: React.FC<{ wsUrl?: string }> = ({ wsUrl }) => { const [interactive, setInteractive] = React.useState(false); - const [tabs, setTabs] = React.useState([]); + const [tabs, setTabs] = React.useState(null); const [url, setUrl] = React.useState(''); const [frame, setFrame] = React.useState(); const [showInspector, setShowInspector] = React.useState(false); @@ -215,13 +215,14 @@ export const DevTools: React.FC<{ wsUrl?: string }> = ({ wsUrl }) => { } } - const selectedTab = tabs.find(t => t.selected); - const hasPages = !!selectedTab; + const selectedTab = tabs?.find(t => t.selected); let overlayText: string | undefined; if (!channel) overlayText = 'Disconnected'; - if (channel && !hasPages) + else if (tabs === null) + overlayText = 'Loading...'; + else if (tabs.length === 0) overlayText = 'No tabs open'; return (
= ({ wsUrl }) => { Sessions
- {tabs.map(tab => ( + {tabs?.map(tab => (
impleme constructor(parent: ChannelOwner, type: string, guid: string, initializer: channels.CDPSessionInitializer) { super(parent, type, guid, initializer); - this._channel.on('event', ({ method, params }) => { - this.emit(method, params); - this.emit('event', { name: method, params }); + this._channel.on('event', event => { + this.emit(event.method, event.params); + this.emit('event', event); }); this._channel.on('close', () => { diff --git a/packages/playwright-core/src/server/chromium/chromium.ts b/packages/playwright-core/src/server/chromium/chromium.ts index 04b6c01053ede..f0b955c13c631 100644 --- a/packages/playwright-core/src/server/chromium/chromium.ts +++ b/packages/playwright-core/src/server/chromium/chromium.ts @@ -349,7 +349,8 @@ export class Chromium extends BrowserType { proxyBypassRules.push('<-loopback>'); if (proxy.bypass) proxyBypassRules.push(...proxy.bypass.split(',').map(t => t.trim()).map(t => t.startsWith('.') ? '*' + t : t)); - if (!process.env.PLAYWRIGHT_DISABLE_FORCED_CHROMIUM_PROXIED_LOOPBACK && !proxyBypassRules.includes('<-loopback>')) + const bypassesLoopback = proxyBypassRules.some(rule => rule === '<-loopback>' || rule === 'localhost' || rule === '127.0.0.1' || rule === '::1'); + if (!process.env.PLAYWRIGHT_DISABLE_FORCED_CHROMIUM_PROXIED_LOOPBACK && !bypassesLoopback) proxyBypassRules.push('<-loopback>'); if (proxyBypassRules.length > 0) chromeArguments.push(`--proxy-bypass-list=${proxyBypassRules.join(';')}`); diff --git a/packages/playwright-core/src/utils/isomorphic/mimeType.ts b/packages/playwright-core/src/utils/isomorphic/mimeType.ts index 9db6ea9376ac1..b58bd543bbe25 100644 --- a/packages/playwright-core/src/utils/isomorphic/mimeType.ts +++ b/packages/playwright-core/src/utils/isomorphic/mimeType.ts @@ -18,6 +18,10 @@ export function isJsonMimeType(mimeType: string) { return !!mimeType.match(/^(application\/json|application\/.*?\+json|text\/(x-)?json)(;\s*charset=.*)?$/); } +export function isXmlMimeType(mimeType: string) { + return !!mimeType.match(/^(application\/xml|application\/.*?\+xml|text\/xml)(;\s*charset=.*)?$/); +} + export function isTextualMimeType(mimeType: string) { return !!mimeType.match(/^(text\/.*?|application\/(json|(x-)?javascript|xml.*?|ecmascript|graphql|x-www-form-urlencoded)|image\/svg(\+xml)?|application\/.*?(\+json|\+xml))(;\s*charset=.*)?$/); } diff --git a/packages/playwright-core/types/types.d.ts b/packages/playwright-core/types/types.d.ts index 2048ef36657b7..c7b0375a83056 100644 --- a/packages/playwright-core/types/types.d.ts +++ b/packages/playwright-core/types/types.d.ts @@ -16058,7 +16058,7 @@ export interface CDPSession { /** * CDP event name. */ - name: string; + method: string; /** * CDP event parameters. @@ -16078,7 +16078,7 @@ export interface CDPSession { /** * CDP event name. */ - name: string; + method: string; /** * CDP event parameters. @@ -16108,7 +16108,7 @@ export interface CDPSession { /** * CDP event name. */ - name: string; + method: string; /** * CDP event parameters. @@ -16128,7 +16128,7 @@ export interface CDPSession { /** * CDP event name. */ - name: string; + method: string; /** * CDP event parameters. @@ -16148,7 +16148,7 @@ export interface CDPSession { /** * CDP event name. */ - name: string; + method: string; /** * CDP event parameters. @@ -16178,7 +16178,7 @@ export interface CDPSession { /** * CDP event name. */ - name: string; + method: string; /** * CDP event parameters. diff --git a/packages/playwright/src/isomorphic/testServerInterface.ts b/packages/playwright/src/isomorphic/testServerInterface.ts index 062b26958c0d0..9fb411c467bbf 100644 --- a/packages/playwright/src/isomorphic/testServerInterface.ts +++ b/packages/playwright/src/isomorphic/testServerInterface.ts @@ -83,6 +83,7 @@ export interface TestServerInterface { locations?: string[]; grep?: string; grepInvert?: string; + onlyChanged?: string; }): Promise<{ report: ReportEntry[], status: reporterTypes.FullResult['status'] diff --git a/packages/playwright/src/runner/testRunner.ts b/packages/playwright/src/runner/testRunner.ts index 564347ac780fe..db6edbead170d 100644 --- a/packages/playwright/src/runner/testRunner.ts +++ b/packages/playwright/src/runner/testRunner.ts @@ -56,6 +56,7 @@ export type ListTestsParams = { locations?: string[]; grep?: string; grepInvert?: string; + onlyChanged?: string; }; export type RunTestsParams = { @@ -271,6 +272,7 @@ export class TestRunner extends EventEmitter { config.cliGrep = params.grep; config.cliGrepInvert = params.grepInvert; config.cliProjectFilter = params.projects?.length ? params.projects : undefined; + config.cliOnlyChanged = params.onlyChanged; config.cliListOnly = true; const status = await runTasks(new TestRun(config, reporter), [ diff --git a/packages/trace-viewer/src/ui/actionList.tsx b/packages/trace-viewer/src/ui/actionList.tsx index 08c2977169a53..8ca695579829e 100644 --- a/packages/trace-viewer/src/ui/actionList.tsx +++ b/packages/trace-viewer/src/ui/actionList.tsx @@ -29,19 +29,106 @@ import { testStatusIcon } from './testUtils'; import { methodMetainfo } from '@isomorphic/protocolMetainfo'; import { formatProtocolParam } from '@isomorphic/protocolFormatter'; +function getTitleFormat(action: ActionTraceEvent): string { + const metaTitle = methodMetainfo.get(`${action.class}.${action.method}`)?.title; + const raw = action.title ?? metaTitle ?? action.method ?? ''; + return String(raw).replace(/\n/g, ' '); +} + +function expandPlaceholders(format: string, params: Record): string { + return format.replace(/\{([^}]+)\}/g, (fullMatch, paramKey) => { + const value = formatProtocolParam(params, paramKey); + return value === undefined ? fullMatch : String(value); + }); +} + +export function getActionSearchText(action: ActionTraceEvent): string { + try { + const titleFormat = getTitleFormat(action); + return expandPlaceholders(titleFormat, action.params ?? {}); + } catch { + return String(action.title ?? action.method ?? ''); + } +} + +function computeVisibleCallIds( + actionFilterText: string | undefined, + itemMap: Map, +): Set | null { + const query = actionFilterText?.trim().toLowerCase(); + if (!query) + return null; + + const matchingCallIds = new Set(); + for (const item of itemMap.values()) { + const callId = item.action.callId; + if (!callId) + continue; + + const searchText = getActionSearchText(item.action).toLowerCase(); + if (searchText.includes(query)) + matchingCallIds.add(callId); + } + + const visibleCallIds = new Set(); + + const addAncestors = (item: ActionTreeItem | undefined) => { + if (!item) + return; + + const callId = item.action.callId; + if (callId && visibleCallIds.has(callId)) + return; + + if (callId) + visibleCallIds.add(callId); + + if (item.parent) + addAncestors(item.parent); + }; + + for (const callId of matchingCallIds) + addAncestors(itemMap.get(callId)); + + for (const callId of matchingCallIds) + visibleCallIds.add(callId); + + return visibleCallIds; +} + +function expandTreeForCallIds( + callIdsToExpand: Set, + itemMap: Map, + previousState: TreeState, +): TreeState { + const expandedItems = new Map(previousState.expandedItems); + + for (const callId of callIdsToExpand) { + const item = itemMap.get(callId); + if (!item) + continue; + + for (let parent: ActionTreeItem | undefined = item.parent; parent && parent.action.callId; parent = parent.parent) + expandedItems.set(parent.action.callId, true); + } + + return { ...previousState, expandedItems }; +} + export interface ActionListProps { actions: ActionTraceEventInContext[], selectedAction: ActionTraceEventInContext | undefined, selectedTime: Boundaries | undefined, setSelectedTime: (time: Boundaries | undefined) => void, treeState: TreeState, - setTreeState: (treeState: TreeState) => void, + setTreeState: React.Dispatch>, sdkLanguage: Language | undefined; onSelected?: (action: ActionTraceEventInContext) => void, onHighlighted?: (action: ActionTraceEventInContext | undefined) => void, revealConsole?: () => void, revealActionAttachment?(callId: string): void, isLive?: boolean, + actionFilterText?: string, } const ActionTreeView = TreeView; @@ -59,6 +146,7 @@ export const ActionList: React.FC = ({ revealConsole, revealActionAttachment, isLive, + actionFilterText, }) => { const { rootItem, itemMap } = React.useMemo(() => buildActionTree(actions), [actions]); @@ -67,6 +155,27 @@ export const ActionList: React.FC = ({ return { selectedItem }; }, [itemMap, selectedAction]); + const visibleCallIds = React.useMemo(() => { + return computeVisibleCallIds(actionFilterText, itemMap); + }, [itemMap, actionFilterText]); + + const prevVisibleCallIdsRef = React.useRef | null>(null); + React.useEffect(() => { + if (visibleCallIds) { + prevVisibleCallIdsRef.current = visibleCallIds; + return; + } + + const previousVisibleCallIds = prevVisibleCallIdsRef.current; + if (!previousVisibleCallIds) + return; + + prevVisibleCallIdsRef.current = null; + setTreeState(previousState => + expandTreeForCallIds(previousVisibleCallIds, itemMap, previousState), + ); + }, [visibleCallIds, itemMap, setTreeState]); + const isError = React.useCallback((item: ActionTreeItem) => { return !!item.action.error?.message; }, []); @@ -81,8 +190,13 @@ export const ActionList: React.FC = ({ }, [isLive, revealConsole, revealActionAttachment, sdkLanguage]); const isVisible = React.useCallback((item: ActionTreeItem) => { - return !selectedTime || !item.action || (item.action.startTime <= selectedTime.maximum && item.action.endTime >= selectedTime.minimum); - }, [selectedTime]); + const timeVisible = !selectedTime || !item.action || (item.action.startTime <= selectedTime.maximum && item.action.endTime >= selectedTime.minimum); + if (!timeVisible) + return false; + if (!visibleCallIds || !item.action.callId) + return true; + return visibleCallIds.has(item.action.callId); + }, [selectedTime, visibleCallIds]); const onSelectedAction = React.useCallback((item: ActionTreeItem) => { onSelected?.(item.action); @@ -92,7 +206,7 @@ export const ActionList: React.FC = ({ onHighlighted?.(item?.action); }, [onHighlighted]); - return
+ return
{selectedTime &&
setSelectedTime(undefined)}>Show all
} = ({ isError={isError} isVisible={isVisible} render={render} + autoExpandDepth={actionFilterText?.trim() ? 5 : 0} />
; }; diff --git a/packages/trace-viewer/src/ui/networkResourceDetails.tsx b/packages/trace-viewer/src/ui/networkResourceDetails.tsx index 855e7538aaa8a..4ca5efc71038f 100644 --- a/packages/trace-viewer/src/ui/networkResourceDetails.tsx +++ b/packages/trace-viewer/src/ui/networkResourceDetails.tsx @@ -24,7 +24,7 @@ import { generateCurlCommand, generateFetchCall } from '../third_party/devtools' import { CopyToClipboardTextButton } from './copyToClipboard'; import { getAPIRequestCodeGen } from './codegen'; import type { Language } from '@isomorphic/locatorGenerators'; -import { isJsonMimeType } from '@isomorphic/mimeType'; +import { isJsonMimeType, isXmlMimeType } from '@isomorphic/mimeType'; import { msToString, useAsyncMemo, useSetting } from '@web/uiUtils'; import type { Entry } from '@trace/har'; import { useTraceModel } from './traceModelContext'; @@ -260,6 +260,34 @@ function statusClass(statusCode: number): string { return 'red-circle'; } +const kInlineTagPattern = /<[^>]+>[^<]*<\//; + +function formatXml(xml: string, indent = ' ') { + let depth = 0; + const lines: string[] = []; + const tokens = xml.replace(/>\s*\n<').split('\n'); + + for (const token of tokens) { + const trimmed = token.trim(); + if (!trimmed) + continue; + + if (trimmed.startsWith('') || trimmed.startsWith(') => void; projectFilters: Map; setProjectFilters: (filters: Map) => void; + onlyChanged: boolean; + setOnlyChanged: (value: boolean) => void; testModel: TeleSuiteUpdaterTestModel | undefined, runTests: () => void; -}> = ({ filterText, setFilterText, statusFilters, setStatusFilters, projectFilters, setProjectFilters, testModel, runTests }) => { +}> = ({ filterText, setFilterText, statusFilters, setStatusFilters, projectFilters, setProjectFilters, onlyChanged, setOnlyChanged, testModel, runTests }) => { const [expanded, setExpanded] = React.useState(false); const inputRef = React.useRef(null); React.useEffect(() => { @@ -53,42 +55,51 @@ export const FiltersView: React.FC<{ runTests(); }} />}> -
setExpanded(!expanded)}> +
setExpanded(!expanded)}> Status: {statusLine} Projects: {projectsLine} + {onlyChanged && <>Only changed}
- {expanded &&
-
- {[...statusFilters.entries()].map(([status, value]) => { - return
- -
; - })} + {expanded && <> +
+
+ {[...statusFilters.entries()].map(([status, value]) => { + return
+ +
; + })} +
+
+ {[...projectFilters.entries()].map(([projectName, value]) => { + return
+ +
; + })} +
-
- {[...projectFilters.entries()].map(([projectName, value]) => { - return
- -
; - })} +
+
-
} + }
; }; diff --git a/packages/trace-viewer/src/ui/uiModeView.tsx b/packages/trace-viewer/src/ui/uiModeView.tsx index 9725a49c974a3..faeba7146831d 100644 --- a/packages/trace-viewer/src/ui/uiModeView.tsx +++ b/packages/trace-viewer/src/ui/uiModeView.tsx @@ -109,6 +109,7 @@ export const UIModeView: React.FC<{}> = ({ const [singleWorker, setSingleWorker] = useSetting('single-worker', false); const [updateSnapshots, setUpdateSnapshots] = useSetting('updateSnapshots', 'missing'); + const [onlyChanged, setOnlyChanged] = useSetting('only-changed', false); const [mergeFiles] = useSetting('mergeFiles', false); const inputRef = React.useRef(null); @@ -197,7 +198,7 @@ export const UIModeView: React.FC<{}> = ({ if (status !== 'passed') return; - const result = await testServerConnection.listTests({ projects: queryParams.projects, locations: queryParams.args, grep: queryParams.grep, grepInvert: queryParams.grepInvert }); + const result = await testServerConnection.listTests({ projects: queryParams.projects, locations: queryParams.args, grep: queryParams.grep, grepInvert: queryParams.grepInvert, onlyChanged: onlyChanged ? 'HEAD' : undefined }); teleSuiteUpdater.processListReport(result.report); testServerConnection.onReport(params => { @@ -213,7 +214,7 @@ export const UIModeView: React.FC<{}> = ({ return () => { clearTimeout(throttleTimer); }; - }, [testServerConnection]); + }, [onlyChanged, testServerConnection]); // Update project filter default values. React.useEffect(() => { @@ -321,7 +322,7 @@ export const UIModeView: React.FC<{}> = ({ commandQueue.current = commandQueue.current.then(async () => { setIsLoading(true); try { - const result = await testServerConnection.listTests({ projects: queryParams.projects, locations: queryParams.args, grep: queryParams.grep, grepInvert: queryParams.grepInvert }); + const result = await testServerConnection.listTests({ projects: queryParams.projects, locations: queryParams.args, grep: queryParams.grep, grepInvert: queryParams.grepInvert, onlyChanged: onlyChanged ? 'HEAD' : undefined }); teleSuiteUpdater.processListReport(result.report); } catch (e) { // eslint-disable-next-line no-console @@ -375,7 +376,7 @@ export const UIModeView: React.FC<{}> = ({ runTests('queue-if-busy', { locations, testIds }); }); return () => disposable.dispose(); - }, [runTests, testServerConnection, watchAll, watchedTreeIds, teleSuiteUpdater, projectFilters, mergeFiles]); + }, [runTests, testServerConnection, watchAll, watchedTreeIds, teleSuiteUpdater, projectFilters, mergeFiles, onlyChanged]); // Shortcuts. React.useEffect(() => { @@ -480,6 +481,8 @@ export const UIModeView: React.FC<{}> = ({ setStatusFilters={setStatusFilters} projectFilters={projectFilters} setProjectFilters={setProjectFilters} + onlyChanged={onlyChanged} + setOnlyChanged={setOnlyChanged} testModel={testModel} runTests={runVisibleTests} /> diff --git a/packages/trace-viewer/src/ui/workbench.css b/packages/trace-viewer/src/ui/workbench.css index 03084c7c11f19..b557d7aa03fa6 100644 --- a/packages/trace-viewer/src/ui/workbench.css +++ b/packages/trace-viewer/src/ui/workbench.css @@ -44,3 +44,32 @@ user-select: none; color: var(--vscode-editorCodeLens-foreground); } + +.workbench-action-filter { + flex: none; + padding: 4px; + border-bottom: 1px solid var(--vscode-panel-border); +} + +.workbench-action-filter input[type=search] { + width: 100%; + box-sizing: border-box; + padding: 4px 8px; + line-height: 20px; + outline: none; + border: 1px solid var(--vscode-input-border); + border-radius: 2px; + color: var(--vscode-input-foreground); + background-color: var(--vscode-input-background); +} + +.workbench-action-filter input[type=search]:focus { + border-color: var(--vscode-focusBorder); +} + +.action-list-container { + min-height: 0; + flex: auto; + display: flex; + flex-direction: column; +} diff --git a/packages/trace-viewer/src/ui/workbench.tsx b/packages/trace-viewer/src/ui/workbench.tsx index 98175a3d11381..59ca7c7819444 100644 --- a/packages/trace-viewer/src/ui/workbench.tsx +++ b/packages/trace-viewer/src/ui/workbench.tsx @@ -88,6 +88,7 @@ const PartitionedWorkbench: React.FunctionComponent('revealedAttachmentCallId'); const [highlightedResourceKey, setHighlightedResourceKey] = usePartitionedState('highlightedResourceKey'); const [treeState, setTreeState] = usePartitionedState('treeState', { expandedItems: new Map() }); + const [actionFilterText, setActionFilterText] = React.useState(''); togglePartition(partition); @@ -321,6 +322,16 @@ const PartitionedWorkbench: React.FunctionComponent
{time ? msToString(time) : ''}
} +
+ setActionFilterText(e.target.value)} + /> +
selectPropertiesTab('console')} isLive={isLive} + actionFilterText={actionFilterText} />
}; diff --git a/tests/assets/network-tab/network.html b/tests/assets/network-tab/network.html index 2544a7bf9d0cf..3bcd58dba33cd 100644 --- a/tests/assets/network-tab/network.html +++ b/tests/assets/network-tab/network.html @@ -32,6 +32,11 @@ headers: { 'Content-Type': 'application/json' }, body, }), + fetch('/post-xml-data', { + method: 'POST', + headers: { 'Content-Type': 'application/xml' }, + body: `Hello & welcome!`, + }), ]; window.donePromise = Promise.all(fetches.map(f => f.then(r => r.body()).then(() => null).catch(e => {}))); diff --git a/tests/library/chromium/session.spec.ts b/tests/library/chromium/session.spec.ts index 5e7789b7bd7b9..830a057514cd8 100644 --- a/tests/library/chromium/session.spec.ts +++ b/tests/library/chromium/session.spec.ts @@ -153,7 +153,7 @@ it('should emit event for each CDP event', async function({ page, server }) { client.on('event', event => events.push(event)); await page.goto(server.EMPTY_PAGE); expect(events.length).toBeGreaterThan(0); - const requestEvent = events.find(e => e.name === 'Network.requestWillBeSent'); + const requestEvent = events.find(e => e.method === 'Network.requestWillBeSent'); expect(requestEvent).toBeTruthy(); expect(requestEvent.params.request.url).toBe(server.EMPTY_PAGE); }); diff --git a/tests/library/proxy.spec.ts b/tests/library/proxy.spec.ts index 9f068fcb016a9..97cfeaf5a07c0 100644 --- a/tests/library/proxy.spec.ts +++ b/tests/library/proxy.spec.ts @@ -228,6 +228,22 @@ it('should exclude patterns', async ({ browserType, server, channel }) => { await browser.close(); }); +it('should bypass proxy for localhost when localhost is in bypass list', async ({ browserType, server, proxyServer }) => { + proxyServer.forwardTo(server.PORT); + server.setRoute('/target.html', async (req, res) => { + res.end('Served by the proxy'); + }); + const browser = await browserType.launch({ + proxy: { server: `localhost:${proxyServer.PORT}`, bypass: 'localhost' } + }); + const page = await browser.newPage(); + // Navigate to localhost - should bypass the proxy and hit the server directly. + await page.goto(`http://localhost:${server.PORT}/target.html`); + expect(proxyServer.requestUrls).not.toContain(`http://localhost:${server.PORT}/target.html`); + expect(await page.title()).toBe('Served by the proxy'); + await browser.close(); +}); + it('should use socks proxy', async ({ browserType, socksPort }) => { const browser = await browserType.launch({ proxy: { server: `socks5://localhost:${socksPort}` } diff --git a/tests/library/trace-viewer.spec.ts b/tests/library/trace-viewer.spec.ts index 656c0fa702d32..d3d12eb2475e9 100644 --- a/tests/library/trace-viewer.spec.ts +++ b/tests/library/trace-viewer.spec.ts @@ -191,6 +191,20 @@ test('should open simple trace viewer', async ({ showTraceViewer }) => { ]); }); +test('should filter actions by text', async ({ showTraceViewer }) => { + const traceViewer = await showTraceViewer(traceFile); + const filterInput = traceViewer.page.getByRole('searchbox', { name: 'Filter actions' }); + await expect(filterInput).toBeVisible(); + + const fullCount = await traceViewer.actionTitles.count(); + await filterInput.fill('Click'); + await expect(traceViewer.actionTitles.filter({ hasText: 'Click' }).first()).toBeVisible(); + expect(await traceViewer.actionTitles.count()).toBeLessThan(fullCount); + + await filterInput.fill(''); + await expect(traceViewer.actionTitles).toHaveCount(fullCount); +}); + test('should open uncompressed trace directory', async ({ showTraceViewer }) => { const traceDir = test.info().outputPath('unzipped-trace'); await extractZip(traceFile, { dir: traceDir }); @@ -478,7 +492,7 @@ test('should filter network requests by multiple resource types', async ({ page, await expect(networkRequests.getByText('image.png')).toBeVisible(); await traceViewer.page.getByText('All', { exact: true }).click(); - await expect(networkRequests).toHaveCount(9); + await expect(networkRequests).toHaveCount(10); }); test('should show font preview', async ({ page, runAndTrace, server }) => { diff --git a/tests/playwright-test/only-changed.spec.ts b/tests/playwright-test/only-changed.spec.ts index 0e02f9f10b621..13bb434670d6c 100644 --- a/tests/playwright-test/only-changed.spec.ts +++ b/tests/playwright-test/only-changed.spec.ts @@ -14,23 +14,7 @@ * limitations under the License. */ -import { test as baseTest, expect, playwrightCtConfigText } from './playwright-test-fixtures'; -import { execSync } from 'node:child_process'; - -const test = baseTest.extend<{ git(command: string): void }>({ - git: async ({}, use, testInfo) => { - const baseDir = testInfo.outputPath(); - - const git = (command: string) => execSync(`git ${command}`, { cwd: baseDir, stdio: process.env.PWTEST_DEBUG ? 'inherit' : 'ignore' }); - - git(`init --initial-branch=main`); - git(`config --local user.name "Robert Botman"`); - git(`config --local user.email "botty@mcbotface.com"`); - git(`config --local core.autocrlf false`); - - await use((command: string) => git(command)); - }, -}); +import { test, expect, playwrightCtConfigText } from './playwright-test-fixtures'; test.slow(); diff --git a/tests/playwright-test/playwright-test-fixtures.ts b/tests/playwright-test/playwright-test-fixtures.ts index 779087bbd77ee..5410698a50134 100644 --- a/tests/playwright-test/playwright-test-fixtures.ts +++ b/tests/playwright-test/playwright-test-fixtures.ts @@ -18,6 +18,7 @@ import type { JSONReport, JSONReportSpec, JSONReportSuite, JSONReportTest, JSONR import * as fs from 'fs'; import * as os from 'os'; import * as path from 'path'; +import { execSync } from 'node:child_process'; import { PNG } from 'playwright-core/lib/utilsBundle'; import type { CommonFixtures, CommonWorkerFixtures, TestChildProcess } from '../config/commonFixtures'; import { commonFixtures } from '../config/commonFixtures'; @@ -252,6 +253,7 @@ export type RunOptions = { type Fixtures = { writeFiles: (files: Files) => Promise; deleteFile: (file: string) => Promise; + git: (command: string) => void; runInlineTest: (files: Files, params?: Params, env?: NodeJS.ProcessEnv, options?: RunOptions) => Promise; runCLICommand: (files: Files, command: string, args?: string[]) => Promise<{ stdout: string, stderr: string, exitCode: number }>; startCLICommand: (files: Files, command: string, args?: string[], options?: RunOptions, env?: NodeJS.ProcessEnv) => Promise; @@ -278,6 +280,16 @@ export const test = base }); }, + git: async ({}, use, testInfo) => { + const baseDir = testInfo.outputPath(); + const git = (command: string) => execSync(`git ${command}`, { cwd: baseDir, stdio: process.env.PWTEST_DEBUG ? 'inherit' : 'ignore' }); + git(`init --initial-branch=main`); + git(`config --local user.name "Robert Botman"`); + git(`config --local user.email "botty@mcbotface.com"`); + git(`config --local core.autocrlf false`); + await use((command: string) => git(command)); + }, + runInlineTest: async ({ childProcess, mergeReports, useIntermediateMergeReport }, use, testInfo: TestInfo) => { const cacheDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), 'playwright-test-cache-')); await use(async (files: Files, params: Params = {}, env: NodeJS.ProcessEnv = {}, options: RunOptions = {}) => { diff --git a/tests/playwright-test/stable-test-runner/package-lock.json b/tests/playwright-test/stable-test-runner/package-lock.json index a416e5507f883..639b37ea79646 100644 --- a/tests/playwright-test/stable-test-runner/package-lock.json +++ b/tests/playwright-test/stable-test-runner/package-lock.json @@ -5,16 +5,16 @@ "packages": { "": { "dependencies": { - "@playwright/test": "^1.59.0-alpha-2026-03-02" + "@playwright/test": "^1.59.0-alpha-2026-03-09" } }, "node_modules/@playwright/test": { - "version": "1.59.0-alpha-2026-03-02", - "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.59.0-alpha-2026-03-02.tgz", - "integrity": "sha512-+jIbWEodqd0b9rQlO/54eStOPiz2L0/Irjt1s2A6Rvwt1Xa82BClSAgWRsNf8dYIjm4kBEOuY0OFyYtM4YWJFw==", + "version": "1.59.0-alpha-2026-03-09", + "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.59.0-alpha-2026-03-09.tgz", + "integrity": "sha512-PXeDcWsvJgqV5dTBAcCX23DrMDDuAOu4kGOqiGvVBLcV+AqGZl06Bp+dmYjA77an43fe1Xhn8ZftHnsfnqtqpA==", "license": "Apache-2.0", "dependencies": { - "playwright": "1.59.0-alpha-2026-03-02" + "playwright": "1.59.0-alpha-2026-03-09" }, "bin": { "playwright": "cli.js" @@ -38,12 +38,12 @@ } }, "node_modules/playwright": { - "version": "1.59.0-alpha-2026-03-02", - "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.59.0-alpha-2026-03-02.tgz", - "integrity": "sha512-R9Il/s0V57X/20/ajbCWpFVuRCA1rXqikWLVb9vuxJOD9i0OR5RrC3tEGkEES2lKTm3b8jX3ETRkJ7e0asg2Dg==", + "version": "1.59.0-alpha-2026-03-09", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.59.0-alpha-2026-03-09.tgz", + "integrity": "sha512-S7idgqb6cxRDrJJEXcRuXNC6W/vgJj5DNMvGGX/lbqmko/Kqshs1Ab1dDg2sUVUPs8Z2vxDqIgOcTjSPN++Abw==", "license": "Apache-2.0", "dependencies": { - "playwright-core": "1.59.0-alpha-2026-03-02" + "playwright-core": "1.59.0-alpha-2026-03-09" }, "bin": { "playwright": "cli.js" @@ -56,9 +56,9 @@ } }, "node_modules/playwright-core": { - "version": "1.59.0-alpha-2026-03-02", - "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.59.0-alpha-2026-03-02.tgz", - "integrity": "sha512-pFBSBbm+kakfDY8J3YxQBl4+OjLBw8Pil/5q5oPPQFROUi1zsIsheGcMqhjV1j0Uaxgm1WNLHUwGZ9p84o1Riw==", + "version": "1.59.0-alpha-2026-03-09", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.59.0-alpha-2026-03-09.tgz", + "integrity": "sha512-Ff3X9IJPIxCyOGmv1C+YzbWEm6Z1Lg6/EEPf1864EaBw30P+dEC+YJGp01FAxYqT8Eev0VXYApv1qwM/e6q9sQ==", "license": "Apache-2.0", "bin": { "playwright-core": "cli.js" diff --git a/tests/playwright-test/stable-test-runner/package.json b/tests/playwright-test/stable-test-runner/package.json index 9c233e50bb6fc..07494039ca9f9 100644 --- a/tests/playwright-test/stable-test-runner/package.json +++ b/tests/playwright-test/stable-test-runner/package.json @@ -1,6 +1,6 @@ { "private": true, "dependencies": { - "@playwright/test": "^1.59.0-alpha-2026-03-02" + "@playwright/test": "^1.59.0-alpha-2026-03-09" } } diff --git a/tests/playwright-test/ui-mode-test-filters.spec.ts b/tests/playwright-test/ui-mode-test-filters.spec.ts index dd59c334b22c6..fefe28f03e104 100644 --- a/tests/playwright-test/ui-mode-test-filters.spec.ts +++ b/tests/playwright-test/ui-mode-test-filters.spec.ts @@ -37,7 +37,7 @@ const basicTestTree = { test('should filter by title', async ({ runUITest }) => { const { page } = await runUITest(basicTestTree); - await page.getByPlaceholder('Filter').fill('inner'); + await page.getByPlaceholder('Filter (e.g. text, @tag)').fill('inner'); await expect.poll(dumpTestTree(page)).toBe(` ▼ ◯ a.test.ts ▼ ◯ suite @@ -48,7 +48,7 @@ test('should filter by title', async ({ runUITest }) => { test('should filter by explicit tags', async ({ runUITest }) => { const { page } = await runUITest(basicTestTree); - await page.getByPlaceholder('Filter').fill('@smoke inner'); + await page.getByPlaceholder('Filter (e.g. text, @tag)').fill('@smoke inner'); await expect.poll(dumpTestTree(page)).toBe(` ▼ ◯ a.test.ts ▼ ◯ suite @@ -65,7 +65,7 @@ test('should display native tags and filter by them on click', async ({ runUITes `, }); await page.locator('.ui-mode-tree-item-title').getByText('smoke').click(); - await expect(page.getByPlaceholder('Filter')).toHaveValue('@smoke'); + await expect(page.getByPlaceholder('Filter (e.g. text, @tag)')).toHaveValue('@smoke'); await expect.poll(dumpTestTree(page)).toBe(` ▼ ◯ a.test.ts ◯ pwt @@ -245,3 +245,33 @@ test('should not show tests filtered with --grep-invert', async ({ runUITest }) await expect.poll(dumpTestTree(page)).toContain('passes'); await expect.poll(dumpTestTree(page)).not.toContain('fails'); }); + +test('should filter by only changed files', async ({ runUITest, git, writeFiles }) => { + const committedFiles = { + 'a.test.ts': ` + import { test, expect } from '@playwright/test'; + test('committed test', () => {}); + `, + }; + + await writeFiles(committedFiles); + git(`add .`); + git(`commit -m init`); + + const { page } = await runUITest({ + ...committedFiles, + 'b.test.ts': ` + import { test, expect } from '@playwright/test'; + test('new untracked test', () => {}); + `, + }); + + await expect.poll(dumpTestTree(page)).toContain('a.test.ts'); + await expect.poll(dumpTestTree(page)).toContain('b.test.ts'); + + await page.getByText('Status:').click(); + await page.getByLabel('Show only changed files').setChecked(true); + + await expect.poll(dumpTestTree(page)).toContain('b.test.ts'); + await expect.poll(dumpTestTree(page)).not.toContain('a.test.ts'); +}); diff --git a/tests/playwright-test/ui-mode-test-network-tab.spec.ts b/tests/playwright-test/ui-mode-test-network-tab.spec.ts index 968a91e5c0036..49220c5b1672b 100644 --- a/tests/playwright-test/ui-mode-test-network-tab.spec.ts +++ b/tests/playwright-test/ui-mode-test-network-tab.spec.ts @@ -80,7 +80,7 @@ test('should filter network requests by multiple resource types', async ({ runUI await page.getByText('Network', { exact: true }).click(); const networkItems = page.getByRole('list', { name: 'Network requests' }).getByRole('listitem'); - await expect(networkItems).toHaveCount(9); + await expect(networkItems).toHaveCount(10); await page.getByText('JS', { exact: true }).click(); await expect(networkItems).toHaveCount(1); @@ -101,7 +101,7 @@ test('should filter network requests by multiple resource types', async ({ runUI await expect(networkItems.getByText('image.png')).toBeVisible(); await page.getByText('All', { exact: true }).click(); - await expect(networkItems).toHaveCount(9); + await expect(networkItems).toHaveCount(10); }); test('should filter network requests by url', async ({ runUITest, server }) => { @@ -189,6 +189,32 @@ test('should format JSON request body', async ({ runUITest, server }) => { ], { useInnerText: true }); }); +test('should format XML request body', async ({ runUITest, server }) => { + const { page } = await runUITest({ + 'network-tab.test.ts': ` + import { test, expect } from '@playwright/test'; + test('network tab test', async ({ page }) => { + await page.goto('${server.PREFIX}/network-tab/network.html'); + await page.evaluate(() => (window as any).donePromise); + }); + `, + }); + + await page.getByText('network tab test').dblclick(); + await expect(page.getByTestId('workbench-run-status')).toContainText('Passed'); + + await page.getByText('Network', { exact: true }).click(); + await page.getByText('post-xml-data').click(); + await page.getByRole('tabpanel', { name: 'Network' }).getByRole('tab', { name: 'Payload' }).click(); + const payloadPanel = page.getByRole('tabpanel', { name: 'Payload' }); + await expect(payloadPanel.locator('.CodeMirror-code .CodeMirror-line')).toHaveText([ + '', + '', + ' Hello & welcome!', + '' + ], { useInnerText: true }); +}); + test('should display list of query parameters (only if present)', async ({ runUITest, server }) => { const { page } = await runUITest({ 'network-tab.test.ts': `