Skip to content

Commit f893715

Browse files
committed
chore: add expectAria to perform
1 parent 27b98b2 commit f893715

File tree

10 files changed

+180
-6
lines changed

10 files changed

+180
-6
lines changed

examples/todomvc/tests/adding-new-todos/add-multiple-todos-2.spec.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { test } from '../fixtures';
1+
import { test, expect } from '../fixtures';
22

33
test.use({
44
agent: {
@@ -16,6 +16,8 @@ test.describe('Adding New Todos', () => {
1616
await page.perform(`Add "Walk the dog" todo item`);
1717
await page.perform(`Add "Read a book" todo item`);
1818

19+
await page.perform('Ensure all three todos appear in the list in order of creation. Use browser_expect_list_visible to verify the list. Do not report_result right away, call browser_expect_list_visible tool. You must.');
20+
1921
await page.perform(`Ensure each todo has an unchecked checkbox`);
2022
await page.perform(`Ensure counter shows "3 items left" (plural)`);
2123
await page.perform(`Ensure input field is cleared`);

examples/todomvc/tests/adding-new-todos/add-multiple-todos-2.spec.ts-cache.json

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -108,5 +108,16 @@
108108
"intent": "Check that the input field is cleared (empty)."
109109
}
110110
]
111+
},
112+
"Ensure all three todos appear in the list in order of creation. Use browser_expect_list_visible to verify the list. Do not report_result right away, call browser_expect_list_visible tool. You must.": {
113+
"timestamp": 1766528380006,
114+
"actions": [
115+
{
116+
"method": "expectAria",
117+
"template": "- list:\n - listitem: Buy groceries\n - listitem: Walk the dog\n - listitem: Read a book",
118+
"code": "await expect(page.locator('body')).toMatchAria(- list:\n - listitem: Buy groceries\n - listitem: Walk the dog\n - listitem: Read a book);",
119+
"intent": "Ensure all three todos (Buy groceries, Walk the dog, Read a book) appear in the list in order of creation."
120+
}
121+
]
111122
}
112123
}

packages/injected/src/ariaSnapshot.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,10 +16,10 @@
1616

1717
import { ariaPropsEqual } from '@isomorphic/ariaSnapshot';
1818
import { escapeRegExp, longestCommonSubstring, normalizeWhiteSpace } from '@isomorphic/stringUtils';
19+
import { yamlEscapeKeyIfNeeded, yamlEscapeValueIfNeeded } from '@isomorphic/yaml';
1920

2021
import { computeBox, getElementComputedStyle, isElementVisible } from './domUtils';
2122
import * as roleUtils from './roleUtils';
22-
import { yamlEscapeKeyIfNeeded, yamlEscapeValueIfNeeded } from './yaml';
2323

2424
import type { AriaProps, AriaRegex, AriaTextValue, AriaRole, AriaTemplateNode } from '@isomorphic/ariaSnapshot';
2525
import type { Box } from './domUtils';

packages/playwright-core/src/server/agent/actionRunner.ts

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,9 @@
1616

1717
import { serializeExpectedTextValues } from '../utils/expectUtils';
1818
import { monotonicTime } from '../../utils/isomorphic/time';
19+
import { parseAriaSnapshotUnsafe } from '../../utils/isomorphic/ariaSnapshot';
1920
import { ProgressController } from '../progress';
21+
import { yaml } from '../../utilsBundle';
2022

2123
import type * as actions from './actions';
2224
import type { Page } from '../page';
@@ -75,7 +77,7 @@ async function innerRunAction(progress: Progress, page: Page, action: actions.Ac
7577
break;
7678
case 'expectVisible': {
7779
const result = await frame.expect(progress, action.selector, { expression: 'to.be.visible', isNot: false });
78-
if (result.errorMessage)
80+
if (!result.matches)
7981
throw new Error(result.errorMessage);
8082
break;
8183
}
@@ -94,6 +96,13 @@ async function innerRunAction(progress: Progress, page: Page, action: actions.Ac
9496
throw new Error(result.errorMessage);
9597
break;
9698
}
99+
case 'expectAria': {
100+
const expectedValue = parseAriaSnapshotUnsafe(yaml, action.template);
101+
const result = await frame.expect(progress, 'body', { expression: 'to.match.aria', expectedValue, isNot: false });
102+
if (!result.matches)
103+
throw new Error(result.errorMessage);
104+
break;
105+
}
97106
}
98107
}
99108

@@ -110,6 +119,7 @@ export function generateActionTimeout(action: actions.Action): number {
110119
return 5000;
111120
case 'expectVisible':
112121
case 'expectValue':
122+
case 'expectAria':
113123
return 1; // one shot
114124
}
115125
}
@@ -127,6 +137,7 @@ export function performActionTimeout(action: actions.Action): number {
127137
return 0; // no timeout
128138
case 'expectVisible':
129139
case 'expectValue':
140+
case 'expectAria':
130141
return 5000; // default expect timeout.
131142
}
132143
}

packages/playwright-core/src/server/agent/actions.ts

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -77,7 +77,24 @@ export type ExpectValue = {
7777
value: string;
7878
};
7979

80-
export type Action = ClickAction | DragAction | HoverAction | SelectOptionAction | PressAction | PressSequentiallyAction | FillAction | SetChecked | ExpectVisible | ExpectValue;
80+
export type ExpectAria = {
81+
method: 'expectAria';
82+
template: string;
83+
};
84+
85+
export type Action =
86+
| ClickAction
87+
| DragAction
88+
| HoverAction
89+
| SelectOptionAction
90+
| PressAction
91+
| PressSequentiallyAction
92+
| FillAction
93+
| SetChecked
94+
| ExpectVisible
95+
| ExpectValue
96+
| ExpectAria;
97+
8198
export type ActionWithCode = Action & {
8299
code: string;
83100
intent?: string;

packages/playwright-core/src/server/agent/agent.ts

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -40,7 +40,17 @@ export async function pagePerform(progress: Progress, page: Page, options: chann
4040
if (await cachedPerform(progress, context, options))
4141
return { turns: 0, inputTokens: 0, outputTokens: 0 };
4242

43-
const { usage } = await perform(progress, context, options.task, undefined, options);
43+
const task = `
44+
### Instructions
45+
- Perform the following task on the page.
46+
- Your reply should be a tool call that performs action the page.
47+
- If you are asked to verify / assert something, don't just examine the snapshot, generate action that verifies / asserts the condition using tool that starts with "browser_expect_".
48+
49+
### Task
50+
${options.task}
51+
`;
52+
53+
const { usage } = await perform(progress, context, task, undefined, options);
4454
await updateCache(context, options);
4555
return usage;
4656
}

packages/playwright-core/src/server/agent/codegen.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@
1515
*/
1616

1717
import { asLocator } from '../../utils/isomorphic/locatorGenerators';
18-
import { escapeWithQuotes, formatObjectOrVoid } from '../../utils/isomorphic/stringUtils';
18+
import { escapeTemplateString, escapeWithQuotes, formatObjectOrVoid } from '../../utils/isomorphic/stringUtils';
1919

2020
import type * as actions from './actions';
2121
import type { Language } from '../../utils/isomorphic/locatorGenerators';
@@ -73,6 +73,9 @@ export async function generateCode(sdkLanguage: Language, action: actions.Action
7373
return `await expect(page.${locator}).toBeChecked({ checked: ${action.value === 'true'} });`;
7474
return `await expect(page.${locator}).toHaveValue(${escapeWithQuotes(action.value)});`;
7575
}
76+
case 'expectAria': {
77+
return `await expect(page.locator('body')).toMatchAria(\`\n${escapeTemplateString(action.template)}\n\`);`;
78+
}
7679
}
7780
// @ts-expect-error
7881
throw new Error('Unknown action ' + action.method);

packages/playwright-core/src/server/agent/tools.ts

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@
1616

1717
import { z } from '../../mcpBundle';
1818
import { getByRoleSelector, getByTextSelector } from '../../utils/isomorphic/locatorUtils';
19+
import { yamlEscapeValueIfNeeded } from '../../utils/isomorphic/yaml';
1920

2021
import type zod from 'zod';
2122
import type * as loopTypes from '@lowire/loop';
@@ -315,6 +316,29 @@ const expectValue = defineTool({
315316
},
316317
});
317318

319+
const expectList = defineTool({
320+
schema: {
321+
name: 'browser_expect_list_visible',
322+
title: 'Expect list visible',
323+
description: 'Expect list is visible on the page, ensures items is present in the element in the exact order',
324+
inputSchema: z.object({
325+
listRole: z.string().describe('Aria role of the list element as in the snapshot'),
326+
listAccessibleName: z.string().optional().describe('Accessible name of the list element as in the snapshot'),
327+
itemRole: z.string().describe('Aria role of the list items as in the snapshot, should all be the same'),
328+
items: z.array(z.string().describe('Text to look for in the list item, can be either from accessible name of self / nested text content')),
329+
}),
330+
},
331+
332+
handle: async (context, params) => {
333+
const template = `- ${params.listRole}:
334+
${params.items.map(item => ` - ${params.itemRole}: ${yamlEscapeValueIfNeeded(item)}`).join('\n')}`;
335+
return await context.runActionAndWait({
336+
method: 'expectAria',
337+
template,
338+
});
339+
},
340+
});
341+
318342
export default [
319343
snapshot,
320344
click,
@@ -327,4 +351,5 @@ export default [
327351
expectVisible,
328352
expectVisibleText,
329353
expectValue,
354+
expectList,
330355
] as ToolDefinition<any>[];

packages/playwright-core/src/utils.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@ export * from './utils/isomorphic/stringUtils';
3131
export * from './utils/isomorphic/time';
3232
export * from './utils/isomorphic/timeoutRunner';
3333
export * from './utils/isomorphic/urlMatch';
34+
export * from './utils/isomorphic/yaml';
3435

3536
export * from './server/utils/ascii';
3637
export * from './server/utils/comparators';
Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,94 @@
1+
/**
2+
* Copyright (c) Microsoft Corporation.
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* http://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
17+
export function yamlEscapeKeyIfNeeded(str: string): string {
18+
if (!yamlStringNeedsQuotes(str))
19+
return str;
20+
return `'` + str.replace(/'/g, `''`) + `'`;
21+
}
22+
23+
export function yamlEscapeValueIfNeeded(str: string): string {
24+
if (!yamlStringNeedsQuotes(str))
25+
return str;
26+
return '"' + str.replace(/[\\"\x00-\x1f\x7f-\x9f]/g, c => {
27+
switch (c) {
28+
case '\\':
29+
return '\\\\';
30+
case '"':
31+
return '\\"';
32+
case '\b':
33+
return '\\b';
34+
case '\f':
35+
return '\\f';
36+
case '\n':
37+
return '\\n';
38+
case '\r':
39+
return '\\r';
40+
case '\t':
41+
return '\\t';
42+
default:
43+
const code = c.charCodeAt(0);
44+
return '\\x' + code.toString(16).padStart(2, '0');
45+
}
46+
}) + '"';
47+
}
48+
49+
function yamlStringNeedsQuotes(str: string): boolean {
50+
if (str.length === 0)
51+
return true;
52+
53+
// Strings with leading or trailing whitespace need quotes
54+
if (/^\s|\s$/.test(str))
55+
return true;
56+
57+
// Strings containing control characters need quotes
58+
if (/[\x00-\x08\x0b\x0c\x0e-\x1f\x7f-\x9f]/.test(str))
59+
return true;
60+
61+
// Strings starting with '-' need quotes
62+
if (/^-/.test(str))
63+
return true;
64+
65+
// Strings containing ':' or '\n' followed by a space or at the end need quotes
66+
if (/[\n:](\s|$)/.test(str))
67+
return true;
68+
69+
// Strings containing '#' preceded by a space need quotes (comment indicator)
70+
if (/\s#/.test(str))
71+
return true;
72+
73+
// Strings that contain line breaks need quotes
74+
if (/[\n\r]/.test(str))
75+
return true;
76+
77+
// Strings starting with indicator characters or quotes need quotes
78+
if (/^[&*\],?!>|@"'#%]/.test(str))
79+
return true;
80+
81+
// Strings containing special characters that could cause ambiguity
82+
if (/[{}`]/.test(str))
83+
return true;
84+
85+
// YAML array starts with [
86+
if (/^\[/.test(str))
87+
return true;
88+
89+
// Non-string types recognized by YAML
90+
if (!isNaN(Number(str)) || ['y', 'n', 'yes', 'no', 'true', 'false', 'on', 'off', 'null'].includes(str.toLowerCase()))
91+
return true;
92+
93+
return false;
94+
}

0 commit comments

Comments
 (0)