Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
53 changes: 41 additions & 12 deletions packages/wdio-session/src/actions/interact.ts
Original file line number Diff line number Diff line change
Expand Up @@ -621,8 +621,8 @@ interface ClickPoint {
sticky?: boolean
/** the center is under a fixed or sticky element, this point of the element is free */
offCenter?: boolean
/** where a hidden link goes */
href?: string
/** a control inside the dialog or banner that covers the element, which closes it: `button "Accept all"` */
dismiss?: string
/** center of the part of the element (or label) in view, in the frame's viewport: the pointer origin the offset of x and y is taken from */
originX: number
originY: number
Expand Down Expand Up @@ -706,17 +706,42 @@ async function clickPoint (target: ResolvedTarget, instant = false): Promise<Cli
target = root.host
}
}
const href = (el as HTMLAnchorElement).href || undefined
const dismissIn = (cover: Element): string | undefined => {
const container = Element.prototype.closest.call(cover, 'dialog, [role="dialog"], [role="alertdialog"], [aria-modal="true"], [id*="cookie" i], [class*="cookie" i], [id*="consent" i], [class*="consent" i], [id*="gdpr" i], [class*="gdpr" i]') as Element | null
if (!container) {
return undefined
}
const preferred = /close|dismiss|reject|decline|×|✕|✖/i
const accepting = /accept|agree|got it|no thanks|continue|\bok\b/i
let fallback: string | undefined
for (const control of Element.prototype.querySelectorAll.call(container, 'button, a[href], [role="button"], input[type="button"], input[type="submit"]') as NodeListOf<Element>) {
if (!visible(control)) {
continue
}
const text = (Element.prototype.getAttribute.call(control, 'aria-label') || Element.prototype.getAttribute.call(control, 'value') || Object.getOwnPropertyDescriptor(HTMLElement.prototype, 'innerText')?.get?.call(control) as string | undefined || '').trim().replace(/\s+/g, ' ').slice(0, 60)
if (!text) {
continue
}
const role = Element.prototype.getAttribute.call(control, 'role') || (control.localName === 'a' ? 'link' : 'button')
if (preferred.test(text)) {
return `${role} "${text}"`
}
if (!fallback && accepting.test(text)) {
fallback = `${role} "${text}"`
}
}
return fallback
}
if (!visible(el)) {
const label = (el as HTMLInputElement).labels?.[0]
if (label && visible(label)) {
const point = center(label)
const cover = coverAt(label, point.x, point.y)
return cover
? { state: 'covered' as const, ...point, cover: describe(cover), sticky: isSticky(cover) }
? { state: 'covered' as const, ...point, cover: describe(cover), sticky: isSticky(cover), dismiss: dismissIn(cover) }
: { state: 'label' as const, ...point, label }
}
return { state: 'hidden' as const, x: 0, y: 0, originX: 0, originY: 0, href }
return { state: 'hidden' as const, x: 0, y: 0, originX: 0, originY: 0 }
}
const { x, y, originX, originY } = center(el)
const cover = coverAt(el, x, y)
Expand All @@ -739,11 +764,17 @@ async function clickPoint (target: ResolvedTarget, instant = false): Promise<Cli
}
}
return cover
? { state: 'covered' as const, x, y, originX, originY, cover: describe(cover), sticky: isSticky(cover), href }
? { state: 'covered' as const, x, y, originX, originY, cover: describe(cover), sticky: isSticky(cover), dismiss: dismissIn(cover) }
: { state: 'ok' as const, x, y, originX, originY }
}, instant) as Promise<ClickPoint>
}

function coveredHint (point: ClickPoint) {
return point.dismiss
? `Close it first: ${point.dismiss}.`
: 'Close or dismiss what is on top first (a cookie banner, dialog or popup), or scroll so the element is free.'
}

/**
* A fixed header or footer covers what the driver scrolled to the edge of
* the viewport. Scrolling the element to the middle once frees it; anything
Expand All @@ -764,16 +795,14 @@ async function clickChecked (session: Session, target: ResolvedTarget) {
const point = await recheckAboveSticky(target, first)
if (point?.state === 'hidden') {
throw usage(
`${target.label} is not visible on the page${point.href ? `; it links to ${point.href}` : ''}.`,
point.href
? 'It may be inside a closed menu, tab or dialog: open that first, or navigate to the link.'
: 'It may be inside a closed menu, tab or dialog: open that first.'
`${target.label} is not visible on the page.`,
'It may be inside a closed menu, tab or dialog: open the menu or section that contains it, scroll to it, or take a new snapshot.'
)
}
if (point?.state === 'covered') {
throw usage(
`${target.label} is covered by ${point.cover}.`,
'Close or dismiss what is on top first (a cookie banner, dialog or popup), or scroll so the element is free.'
coveredHint(point)
)
}
if (point?.state === 'label' || (point?.state === 'ok' && (point !== first || point.offCenter))) {
Expand All @@ -799,7 +828,7 @@ async function clickChecked (session: Session, target: ResolvedTarget) {
if (again?.state === 'covered') {
throw usage(
`${target.label} is covered by ${again.cover}.`,
'Close or dismiss what is on top first (a cookie banner, dialog or popup), or scroll so the element is free.'
coveredHint(again)
)
}
throw err
Expand Down
56 changes: 45 additions & 11 deletions packages/wdio-session/src/actions/observe.ts
Original file line number Diff line number Diff line change
Expand Up @@ -382,6 +382,17 @@ function capLine (line: string, index = 0) {
return `${start ? '…' : ''}${line.slice(start, end)}${end < line.length ? '…' : ''}`
}

/** the find flags a hint repeats to show the next matches the same way */
function repeatFindFlags (args: Record<string, unknown>) {
return [
args.regex ? ' --regex' : '',
typeof args.scope === 'string' ? ` --scope ${shellQuote(args.scope)}` : '',
typeof args.context === 'number' ? ` -C ${args.context}` : '',
typeof args.afterContext === 'number' ? ` -A ${args.afterContext}` : '',
typeof args.beforeContext === 'number' ? ` -B ${args.beforeContext}` : ''
].join('')
}

export const find: ActionFn = async (session, args) => {
const query = String(args.text ?? '')
const scope = typeof args.scope === 'string' ? args.scope : undefined
Expand Down Expand Up @@ -417,6 +428,12 @@ export const find: ActionFn = async (session, args) => {
hint: 'Try a shorter text or --regex; elements that only appear after a click (menus, popovers) are not on the page yet.'
})
}
const total = matches.length
const skip = typeof args.offset === 'number' && args.offset > 0 ? Math.floor(args.offset) : 0
if (skip >= total) {
throw new SessionError('NO_MATCH', `--offset ${skip} is past the last of ${total} match${total === 1 ? '' : 'es'} for ${JSON.stringify(query)}.`)
}
matches = matches.slice(skip)
const out: string[] = []
let last = -1
let size = 0
Expand Down Expand Up @@ -454,15 +471,16 @@ export const find: ActionFn = async (session, args) => {
last = blockEnd
}
const rest = matches.filter((i) => i > last).length
// the data is capped like the text: `--json` output must not explode either
const listed = matches.filter((i) => i <= last)
if (rest) {
out.push(`… ${rest} more matching line${rest === 1 ? '' : 's'} not shown. Search for a longer text, or narrow it with --scope.`)
const next = skip + listed.length
out.push(`… ${rest} more matching line${rest === 1 ? '' : 's'} not shown. \`${session.cmd('find', { ...hintArgs(args), offset: next }, `wdio session find ${shellQuote(query)}${repeatFindFlags(args)} --offset ${next}`)}\` shows the next ones, or search for a longer text, or narrow it with --scope.`)
}
if (!hidden) {
await scrollToFirst(session, lines, matches[0])
}
// the data is capped like the text: `--json` output must not explode either
const listed = matches.filter((i) => i <= last)
return { text: note + out.join('\n'), data: { matches: listed.map((i) => ({ line: i + 1, text: capLine(lines[i], matchIndex(lines[i], query, regex)) })), total: matches.length, hidden } }
return { text: note + out.join('\n'), data: { matches: listed.map((i) => ({ line: i + 1, text: capLine(lines[i], matchIndex(lines[i], query, regex)) })), total, hidden } }
}

/**
Expand All @@ -482,13 +500,16 @@ async function scrollToFirst (session: Session, lines: string[], first: number)
return
}
try {
const el = await session.refs.resolve(scopeOf(session), id)
await scopeOf(session).execute((node: HTMLElement) => node.scrollIntoView({ block: 'center', inline: 'nearest' }), el as unknown as HTMLElement)
await scrollIntoCenter(session, await session.refs.resolve(scopeOf(session), id))
} catch {
// a ref of an inline frame or a removed element
}
}

async function scrollIntoCenter (session: Session, el: unknown) {
await scopeOf(session).execute((node: HTMLElement) => node.scrollIntoView({ block: 'center', inline: 'nearest' }), el as HTMLElement)
}

export const diff: ActionFn = async (session, args) => {
let before = session.lastSnapshot
if (typeof args.baseline === 'string') {
Expand Down Expand Up @@ -650,20 +671,27 @@ const DEFAULT_READ_CHARS = 6000
*/
export const read: ActionFn = async (session, args) => {
const maxChars = typeof args.maxChars === 'number' && args.maxChars > 0 ? args.maxChars : DEFAULT_READ_CHARS
const offset = typeof args.offset === 'number' && args.offset > 0 ? Math.floor(args.offset) : 0
const scope = typeof args.scope === 'string' ? (await resolveTarget(session, args.scope)).element : undefined
if (scope && session.isWeb && await scope.isDisplayed().catch(() => false)) {
// a screenshot of the page shows what was read
await scrollIntoCenter(session, scope).catch(() => {})
}
const text = await scopeOf(session).execute(function (root: Element | undefined, limit: number) {
const start = root || document.querySelector('main, [role="main"], article') || document.body
const SKIP = new Set(['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEMPLATE', 'SVG', 'CANVAS', 'IFRAME', 'NAV', 'FOOTER', 'ASIDE'])
const BLOCK = new Set(['P', 'DIV', 'SECTION', 'ARTICLE', 'MAIN', 'HEADER', 'FORM', 'FIELDSET', 'BLOCKQUOTE', 'PRE', 'FIGURE', 'FIGCAPTION', 'DL', 'DT', 'DD', 'ADDRESS'])
const out: string[] = []
let size = 0
let line = ''
let cut = false
const flush = (prefix = '') => {
const text = line.replace(/\s+/g, ' ').trim()
if (text && size < limit) {
// one long paragraph is cut too, not only the lines after it
const room = Math.max(0, limit - size - prefix.length)
const kept = text.length > room ? `${text.slice(0, room)}…` : text
cut = text.length > room
const kept = cut ? `${text.slice(0, room)}…` : text
out.push(prefix + kept)
size += prefix.length + kept.length + 1
}
Expand Down Expand Up @@ -738,11 +766,17 @@ export const read: ActionFn = async (session, args) => {
}
walk(start)
flush()
return { text: out.join('\n'), truncated: size >= limit, from: start === document.body ? 'body' : start.tagName.toLowerCase() }
}, scope, maxChars) as { text: string, truncated: boolean, from: string }
return { text: out.join('\n'), truncated: size >= limit, cut, from: start === document.body ? 'body' : start.tagName.toLowerCase() }
}, scope, offset + maxChars) as { text: string, truncated: boolean, cut: boolean, from: string }
if (offset >= text.text.length && offset > 0 && !text.truncated) {
return { text: `--offset ${offset} is past the end of the text (${text.text.length} characters).`, data: { chars: 0 } }
}
if (!text.text) {
return { text: `The page has no readable text here. \`${session.cmd('snapshot', undefined, 'wdio session snapshot')}\` shows its elements.`, data: { chars: 0 } }
}
const tail = text.truncated ? `\n… cut at ${maxChars} characters; --max-chars or --scope reads more or a part.` : ''
return { text: text.text + tail, data: { chars: text.text.length, truncated: text.truncated, from: text.from } }
const shown = text.text.slice(offset)
// the cut paragraph's "…" is not page text: the next part starts before it
const next = offset + shown.length - (text.cut ? 1 : 0)
const tail = text.truncated ? `\n… ${offset ? `characters ${offset}–${next}` : `cut at ${maxChars} characters`}; \`${session.cmd('read', { ...hintArgs(args), offset: next }, `wdio session read${typeof args.scope === 'string' ? ` --scope ${shellQuote(args.scope)}` : ''}${typeof args.maxChars === 'number' ? ` --max-chars ${maxChars}` : ''} --offset ${next}`)}\` reads the next part, --scope reads a section.` : ''
return { text: shown + tail, data: { chars: shown.length, truncated: text.truncated, from: text.from } }
}
8 changes: 5 additions & 3 deletions packages/wdio-session/src/actions/specs.ts
Original file line number Diff line number Diff line change
Expand Up @@ -248,10 +248,11 @@ export const ACTIONS = [
{
name: 'read', group: 'Observation', applies: ['W'],
desc: 'Read the page text as Markdown',
details: 'Headings, paragraphs, list items, table rows and links with their URL, from the main content when the page marks it (main, article), else the whole page; navigation, footers and hidden text are left out. Cut at --max-chars (default 6000). Use it to answer "what does the page say"; use snapshot or find for refs to act on.',
details: 'Headings, paragraphs, list items, table rows and links with their URL, from the main content when the page marks it (main, article), else the whole page; navigation, footers and hidden text are left out. Cut at --max-chars (default 6000); the cut says which --offset reads the next part. With --scope, the section is scrolled into view. Use it to answer "what does the page say"; use snapshot or find for refs to act on.',
options: {
scope: { type: 'string', desc: 'Only read below this ref or selector' },
'max-chars': { type: 'number', desc: 'Print up to this many characters (default 6000)' }
'max-chars': { type: 'number', desc: 'Print up to this many characters (default 6000)' },
offset: { type: 'number', desc: 'Start at this character of the text, for the next part of a long page' }
},
examples: [
['wdio session read', 'Read the main content'],
Expand All @@ -269,7 +270,8 @@ export const ACTIONS = [
scope: { type: 'string', desc: 'Only search below this ref or selector' },
context: { type: 'number', alias: 'C', desc: 'Lines of context before and after instead of the surrounding node' },
'after-context': { type: 'number', alias: 'A', desc: 'Lines of context after each match' },
'before-context': { type: 'number', alias: 'B', desc: 'Lines of context before each match' }
'before-context': { type: 'number', alias: 'B', desc: 'Lines of context before each match' },
offset: { type: 'number', desc: 'Skip this many matches, for the next ones when the output is cut' }
},
examples: [
['wdio session find "Add to cart"', 'Find the ref of a button'],
Expand Down
8 changes: 8 additions & 0 deletions packages/wdio-session/src/cli/command.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,9 @@ import { printError, printResult, useColor, type OutputOptions } from './output.
import { spawnDaemon, waitForExit } from './spawn.js'
import type { ActionResult, StateFile } from '../types.js'

/** a longer wait on one action is a hung page; `wait` sets its own limit */
export const MAX_ACTION_TIMEOUT_MS = 60_000

export interface CliIO {
stdin?: NodeJS.ReadStream
stdout?: NodeJS.WriteStream
Expand Down Expand Up @@ -214,6 +217,11 @@ export async function runSessionCli (rawArgs: string[], io: CliIO = {}): Promise
ctx.timeout += 5_000
}

if (!spec.local && spec.name !== 'wait' && ctx.timeout !== undefined && ctx.timeout > MAX_ACTION_TIMEOUT_MS) {
stderr.write(`--timeout ${ctx.timeout} is capped at ${MAX_ACTION_TIMEOUT_MS} ms: an action that has not finished by then is stuck, not slow.\n`)
ctx.timeout = MAX_ACTION_TIMEOUT_MS
}

try {
const result = await runAction(spec, actionArgs, ctx)
printResult(result, output)
Expand Down
2 changes: 1 addition & 1 deletion packages/wdio-session/src/cli/help.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ import type { Applies } from '../types.js'
export const GLOBAL_OPTIONS: Record<string, Options> = {
session: { alias: 's', type: 'string', desc: 'Session name (env WDIO_SESSION, default "default")', global: true },
json: { type: 'boolean', desc: 'Print one JSON object (env WDIO_SESSION_JSON=1)', global: true },
timeout: { type: 'number', desc: 'Request timeout in ms', global: true },
timeout: { type: 'number', desc: 'Request timeout in ms (capped at 60000 except for wait)', global: true },
quiet: { alias: 'q', type: 'boolean', desc: 'Print nothing on success except requested data', global: true },
color: { type: 'boolean', desc: 'Use --no-color to disable colors', global: true }
}
Expand Down
2 changes: 1 addition & 1 deletion packages/wdio-session/src/daemon/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -199,7 +199,7 @@ export class SessionServer {
reject(new SessionError(
'TIMEOUT',
`"${req.action}" did not finish within ${timeout}ms.`,
{ hint: 'The session is still usable. Increase --timeout if the action needs longer.' }
{ hint: 'The page may be hung, so a longer --timeout will not help. Check that it responds (`snapshot`), then `reload` it or open a new tab (`tabs new <url>`). The session is still usable.' }
))
}, timeout)
})
Expand Down
4 changes: 4 additions & 0 deletions packages/wdio-session/src/exec/hints.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,10 @@ const HINTS: Hint[] = [
test: (e) => /executeAsync is not a function/.test(e.message),
hint: 'Removed in v10: use `browser.execute` with an async function.'
},
{
test: (e) => /Passing a boolean to `getHTML` was removed/.test(e.message),
hint: 'Removed in v10: pass an options object, `getHTML({ includeSelectorTag, prettify })`, e.g. `getHTML({ includeSelectorTag: false })` for the inner HTML.'
},
{
test: (e) => /touchAction is not a function/.test(e.message),
hint: 'Removed in v10: use `browser.action(\'pointer\')` or mobile commands like `tap`/`swipe`.'
Expand Down
2 changes: 1 addition & 1 deletion packages/wdio-session/src/skill/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,7 @@ Run `npx wdio session <action> --help` only when an action fails or you need a f

## Code

Use `exec` for loops, conditions and assertions. Pipe longer code on stdin:
Use `exec` for loops, conditions and assertions. It runs in Node with `browser` and `$` in scope; page code goes in `await browser.execute(() => …)`. Pipe longer code on stdin:

```sh
npx wdio session exec -e 'await expect($("h1")).toHaveText("Cart")'
Expand Down
2 changes: 2 additions & 0 deletions packages/wdio-session/src/targets/browser.ts
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,8 @@ export function browserPlan (target: BrowserTarget, args: OpenArgs, { cwd, platf
...(headless ? ['--headless=new', '--enable-unsafe-swiftshader'] : []),
`--window-size=${viewport.width},${viewport.height}`,
...(args.profile ? [`--user-data-dir=${fsPath.resolve(cwd, String(args.profile))}`] : []),
// keeps `navigator.webdriver` false; Chrome honors one such switch, so a user's own wins
...(extraArgs.some((arg) => arg.startsWith('--disable-blink-features')) ? [] : ['--disable-blink-features=AutomationControlled']),
...extraArgs
]
options.args = browserArgs
Expand Down
Loading
Loading