diff --git a/packages/wdio-session/src/actions/interact.ts b/packages/wdio-session/src/actions/interact.ts index 303aadbd0e..599b79df03 100644 --- a/packages/wdio-session/src/actions/interact.ts +++ b/packages/wdio-session/src/actions/interact.ts @@ -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 @@ -706,17 +706,42 @@ async function clickPoint (target: ResolvedTarget, instant = false): Promise { + 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) { + 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) @@ -739,11 +764,17 @@ async function clickPoint (target: ResolvedTarget, instant = false): Promise } +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 @@ -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))) { @@ -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 diff --git a/packages/wdio-session/src/actions/observe.ts b/packages/wdio-session/src/actions/observe.ts index 4ee7a583f8..5dfe78996e 100644 --- a/packages/wdio-session/src/actions/observe.ts +++ b/packages/wdio-session/src/actions/observe.ts @@ -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) { + 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 @@ -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 @@ -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 } } } /** @@ -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') { @@ -650,7 +671,12 @@ 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']) @@ -658,12 +684,14 @@ export const read: ActionFn = async (session, args) => { 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 } @@ -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 } } } diff --git a/packages/wdio-session/src/actions/specs.ts b/packages/wdio-session/src/actions/specs.ts index 3c0d6ec7d2..7bd9a633aa 100644 --- a/packages/wdio-session/src/actions/specs.ts +++ b/packages/wdio-session/src/actions/specs.ts @@ -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'], @@ -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'], diff --git a/packages/wdio-session/src/cli/command.ts b/packages/wdio-session/src/cli/command.ts index 905d4597d6..fa53b6319b 100644 --- a/packages/wdio-session/src/cli/command.ts +++ b/packages/wdio-session/src/cli/command.ts @@ -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 @@ -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) diff --git a/packages/wdio-session/src/cli/help.ts b/packages/wdio-session/src/cli/help.ts index 87f6f9e095..067d480522 100644 --- a/packages/wdio-session/src/cli/help.ts +++ b/packages/wdio-session/src/cli/help.ts @@ -6,7 +6,7 @@ import type { Applies } from '../types.js' export const GLOBAL_OPTIONS: Record = { 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 } } diff --git a/packages/wdio-session/src/daemon/server.ts b/packages/wdio-session/src/daemon/server.ts index 1085ba82da..8e72c497de 100644 --- a/packages/wdio-session/src/daemon/server.ts +++ b/packages/wdio-session/src/daemon/server.ts @@ -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 `). The session is still usable.' } )) }, timeout) }) diff --git a/packages/wdio-session/src/exec/hints.ts b/packages/wdio-session/src/exec/hints.ts index 9a7e63d483..01cbd2b066 100644 --- a/packages/wdio-session/src/exec/hints.ts +++ b/packages/wdio-session/src/exec/hints.ts @@ -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`.' diff --git a/packages/wdio-session/src/skill/SKILL.md b/packages/wdio-session/src/skill/SKILL.md index 5c2bc3c9e1..a9a40640e7 100644 --- a/packages/wdio-session/src/skill/SKILL.md +++ b/packages/wdio-session/src/skill/SKILL.md @@ -45,7 +45,7 @@ Run `npx wdio session --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")' diff --git a/packages/wdio-session/src/targets/browser.ts b/packages/wdio-session/src/targets/browser.ts index ba7700bacc..a5ca5ba175 100644 --- a/packages/wdio-session/src/targets/browser.ts +++ b/packages/wdio-session/src/targets/browser.ts @@ -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 diff --git a/packages/wdio-session/src/targets/cloud.ts b/packages/wdio-session/src/targets/cloud.ts index 935684b488..fc6facae63 100644 --- a/packages/wdio-session/src/targets/cloud.ts +++ b/packages/wdio-session/src/targets/cloud.ts @@ -161,6 +161,7 @@ function stripLocalBrowserOptions (caps: Record) { !arg.startsWith('--user-data-dir') && arg !== '--disable-gpu' && arg !== '--enable-unsafe-swiftshader' && + arg !== '--disable-blink-features=AutomationControlled' && arg !== '-headless' && !arg.startsWith('--width') && !arg.startsWith('--height') && diff --git a/packages/wdio-session/tests/actions/interact.test.ts b/packages/wdio-session/tests/actions/interact.test.ts index 9e14dcd6cd..1ef3f62132 100644 --- a/packages/wdio-session/tests/actions/interact.test.ts +++ b/packages/wdio-session/tests/actions/interact.test.ts @@ -301,13 +301,25 @@ describe('click', () => { expect(result.text).toBe('Clicked e2 (textbox "Name")') }) - it('fails at once for a hidden element, naming where a link goes', async () => { + it('fails at once for a hidden element, without leaking where a link goes', async () => { const element = { execute: async () => ({ state: 'hidden', x: 0, y: 0, href: 'https://a.test/nutrition' }), click: async () => { throw new Error('should not click') } } - await expect(click(clickSession(element), { target: 'e2', $cwd: '/' })) - .rejects.toThrow('e2 (textbox "Name") is not visible on the page; it links to https://a.test/nutrition.') + const err = await click(clickSession(element), { target: 'e2', $cwd: '/' }).catch((e) => e) + expect(err.message).toBe('e2 (textbox "Name") is not visible on the page.') + expect(JSON.stringify([err.message, err.hint])).not.toContain('https://a.test/nutrition') + expect(err.hint).toContain('take a new snapshot') + expect(err.hint).not.toContain('navigate') + }) + + it('names the control that dismisses the dialog covering an element', async () => { + const element = { + execute: async () => ({ state: 'covered', x: 5, y: 5, cover: 'dialog "Cookie settings"', dismiss: 'button "Accept all"' }), + click: async () => { throw new Error('should not click') } + } + const err = await click(clickSession(element), { target: 'e2', $cwd: '/' }).catch((e) => e) + expect(err.hint).toBe('Close it first: button "Accept all".') }) it('fails at once for a covered element, naming what covers it', async () => { diff --git a/packages/wdio-session/tests/actions/paging.test.ts b/packages/wdio-session/tests/actions/paging.test.ts new file mode 100644 index 0000000000..ee7d966c53 --- /dev/null +++ b/packages/wdio-session/tests/actions/paging.test.ts @@ -0,0 +1,106 @@ +import { describe, expect, it, vi } from 'vitest' + +import { find, read } from '../../src/actions/observe.js' +import { RefRegistry } from '@wdio/snapshot' +import type { SnapshotNode } from '@wdio/snapshot' +import { cmdWith } from '../../src/hints.js' +import type { Session } from '../../src/session.js' + +const refs = [{ id: 'e1', role: 'button', name: 'Add', candidates: [{ kind: 'testid', selector: '[data-testid="add"]' }] }] +const items: SnapshotNode[] = Array.from({ length: 40 }, (_, i) => ({ role: 'paragraph', name: `needle number ${i} ${'x'.repeat(200)}` })) + +function findSession () { + return { + isWeb: true, + applies: ['W'], + cmd: cmdWith(), + currentUrl: async () => 'https://shop.test/', + refs: new RefRegistry(), + get: () => undefined, + browser: { + execute: async () => ({ tree: { role: 'document', name: 'Shop', children: structuredClone(items) }, refs, counter: 1 }) + } + } as unknown as Session +} + +describe('find --offset', () => { + const printed = (text: string) => text.split('\n').filter((line) => /^\d+:/.test(line)) + + it('skips matches and says which offset shows the next ones', async () => { + const first = await find(findSession(), { text: 'needle' }) + const shown = printed(first.text) + expect(shown.length).toBeLessThan(40) + const next = /--offset (\d+)/.exec(first.text)![1] + expect(Number(next)).toBe(shown.length) + + const second = await find(findSession(), { text: 'needle', offset: Number(next) }) + expect(printed(second.text)[0]).toContain(`needle number ${next} `) + expect(printed(second.text)[0]).not.toBe(shown[0]) + expect((second.data as { total: number }).total).toBe(40) + }) + + it('fails past the last match', async () => { + await expect(find(findSession(), { text: 'needle', offset: 40 })).rejects.toThrow('--offset 40 is past the last of 40 matches') + }) +}) + +function readSession (page: { text: string, truncated: boolean, cut?: boolean }, displayed = true) { + const execute = vi.fn(async (_fn: unknown, ...args: unknown[]) => args.length === 1 ? undefined : { from: 'main', cut: false, ...page }) + const element = { elementId: 'el-1', isDisplayed: async () => displayed } + const session = { + isWeb: true, + applies: ['W'], + cmd: cmdWith(), + get: () => undefined, + refs: new RefRegistry(), + browser: { execute, $: () => ({ getElement: async () => element }) } + } as unknown as Session + return { session, execute } +} + +describe('read --offset', () => { + it('asks the page for offset + max-chars and prints from the offset', async () => { + const { session, execute } = readSession({ text: '0123456789', truncated: true }) + const result = await read(session, { maxChars: 4, offset: 6 }) + expect(execute.mock.calls[0][2]).toBe(10) + expect(result.text.startsWith('6789\n')).toBe(true) + expect(result.text).toContain('characters 6–10') + expect(result.text).toContain('--offset 10') + }) + + it('leaves out the ellipsis of a cut paragraph from the next offset', async () => { + const { session } = readSession({ text: '0123456…', truncated: true, cut: true }) + expect((await read(session, { maxChars: 4, offset: 2 })).text).toContain('--offset 7') + }) + + it('names the offset when the first part is cut', async () => { + const { session } = readSession({ text: 'abcd', truncated: true }) + expect((await read(session, { maxChars: 4 })).text).toContain('--offset 4') + }) + + it('says so when the offset is past the end', async () => { + const { session } = readSession({ text: 'abc', truncated: false }) + expect((await read(session, { offset: 50 })).text).toContain('--offset 50 is past the end') + }) +}) + +describe('read --scope scrolls', () => { + it('scrolls the scoped element into view', async () => { + const { session, execute } = readSession({ text: 'abc', truncated: false }) + await read(session, { scope: 'main' }) + expect(execute.mock.calls[0]).toHaveLength(2) + }) + + it('does not scroll a hidden element', async () => { + const { session, execute } = readSession({ text: 'abc', truncated: false }, false) + await read(session, { scope: 'main' }) + expect(execute).toHaveBeenCalledTimes(1) + expect(execute.mock.calls[0]).toHaveLength(3) + }) + + it('does not scroll without a scope', async () => { + const { session, execute } = readSession({ text: 'abc', truncated: false }) + await read(session, {}) + expect(execute).toHaveBeenCalledTimes(1) + }) +}) diff --git a/packages/wdio-session/tests/cli/help.test.ts b/packages/wdio-session/tests/cli/help.test.ts index d2d23eda42..d0cb290b2b 100644 --- a/packages/wdio-session/tests/cli/help.test.ts +++ b/packages/wdio-session/tests/cli/help.test.ts @@ -174,6 +174,25 @@ describe('help output', () => { expect(stderr.text()).toBe('') }) + describe('--timeout cap', () => { + const run = async (args: string[]) => { + const stderr = capture() + await runSessionCli(args, { stdout: capture().stream, stderr: stderr.stream, env: { WDIO_SESSION_DIR: '/nonexistent-wdio-session-dir' } }) + return stderr.text() + } + + it('caps an action timeout at 60000 ms and says so once', async () => { + const text = await run(['click', 'e1', '--timeout', '120000']) + expect(text.match(/is capped at 60000 ms/g)).toHaveLength(1) + expect(text).toContain('--timeout 120000') + }) + + it('leaves a timeout within the cap and wait alone', async () => { + expect(await run(['click', 'e1', '--timeout', '60000'])).not.toContain('capped') + expect(await run(['wait', 'e1', '--timeout', '120000'])).not.toContain('capped') + }) + }) + it('rejects help for an unknown action', async () => { const stdout = capture() const stderr = capture() diff --git a/packages/wdio-session/tests/daemon/server.test.ts b/packages/wdio-session/tests/daemon/server.test.ts index 2e826c193a..86f6b0d5d0 100644 --- a/packages/wdio-session/tests/daemon/server.test.ts +++ b/packages/wdio-session/tests/daemon/server.test.ts @@ -99,7 +99,7 @@ describe('SessionServer', () => { const slow = s.enqueue(request(s.token, 'slow', { timeout: 20 })) await new Promise((r) => setTimeout(r, 5)) const fast = s.enqueue(request(s.token, 'fast')) - expect(await slow).toMatchObject({ ok: false, error: { code: 'TIMEOUT' } }) + expect(await slow).toMatchObject({ ok: false, error: { code: 'TIMEOUT', hint: expect.stringContaining('The page may be hung') } }) expect(await fast).toMatchObject({ ok: true, result: { text: 'fast' } }) expect(order).toEqual(['start slow', 'end slow', 'start fast', 'end fast']) }) diff --git a/packages/wdio-session/tests/exec/hints.test.ts b/packages/wdio-session/tests/exec/hints.test.ts index 49a65df3e1..7ce6f059de 100644 --- a/packages/wdio-session/tests/exec/hints.test.ts +++ b/packages/wdio-session/tests/exec/hints.test.ts @@ -13,6 +13,7 @@ describe('exec hints', () => { ['browser.waitForExist is not a function', 'The v4 `browser.waitForExist(selector)` style was removed. Use `await $(selector).waitForExist()`.'], ['browser.executeAsync is not a function', 'Removed in v10: use `browser.execute` with an async function.'], ['browser.touchAction is not a function', 'Removed in v10: use `browser.action(\'pointer\')` or mobile commands like `tap`/`swipe`.'], + ['Passing a boolean to `getHTML` was removed in WebdriverIO v10. Use `element.getHTML({ includeSelectorTag: true })`.', 'Removed in v10: pass an options object, `getHTML({ includeSelectorTag, prettify })`, e.g. `getHTML({ includeSelectorTag: false })` for the inner HTML.'], ['element ("h2") still not existing after 3000ms', 'Take a new `wdio session snapshot`; the page may have changed.'] ])('%s', (message, hint) => { expect(hintFor({ message })).toBe(hint) diff --git a/packages/wdio-session/tests/targets/browser.test.ts b/packages/wdio-session/tests/targets/browser.test.ts index d6e60cda47..35ee3c9165 100644 --- a/packages/wdio-session/tests/targets/browser.test.ts +++ b/packages/wdio-session/tests/targets/browser.test.ts @@ -12,7 +12,7 @@ describe('browser targets', () => { browserName: 'chrome', webSocketUrl: true, pageLoadStrategy: 'eager', - 'goog:chromeOptions': { args: ['--headless=new', '--enable-unsafe-swiftshader', '--window-size=1280,720'] } + 'goog:chromeOptions': { args: ['--headless=new', '--enable-unsafe-swiftshader', '--window-size=1280,720', '--disable-blink-features=AutomationControlled'] } }) expect(plan).toMatchObject({ headless: true, display: false, detach: false, viewport: { width: 1280, height: 720 } }) }) @@ -25,12 +25,18 @@ describe('browser targets', () => { pageLoadStrategy: 'eager', browserVersion: '130', 'ms:edgeOptions': { - args: ['--headless=new', '--enable-unsafe-swiftshader', '--window-size=800,600', '--user-data-dir=/repo/p', '--lang=de'], + args: ['--headless=new', '--enable-unsafe-swiftshader', '--window-size=800,600', '--user-data-dir=/repo/p', '--disable-blink-features=AutomationControlled', '--lang=de'], binary: '/repo/bin/edge' } }) }) + it('does not duplicate a user-supplied --disable-blink-features', () => { + const plan = browserPlan('chrome', { target: 'chrome', arg: ['--disable-blink-features=Foo'] }, { cwd: '/repo', platform: 'linux', env: {} }) + const args = (plan.capabilities['goog:chromeOptions'] as { args: string[] }).args + expect(args.filter((arg) => arg.startsWith('--disable-blink-features'))).toEqual(['--disable-blink-features=Foo']) + }) + it('builds Firefox capabilities', () => { const plan = browserPlan('firefox', { target: 'firefox', headed: true }, { cwd: '/repo', platform: 'linux', env: { DISPLAY: ':0' } }) expect(plan.capabilities).toEqual({ browserName: 'firefox', webSocketUrl: true, pageLoadStrategy: 'eager', 'moz:firefoxOptions': { args: ['--width=1280', '--height=720'] } }) diff --git a/website/docs/session/commands.md b/website/docs/session/commands.md index 87c3f3c1b7..7963e30eeb 100644 --- a/website/docs/session/commands.md +++ b/website/docs/session/commands.md @@ -19,7 +19,7 @@ npx wdio session [arguments] [flags] | --- | --- | | `-s, --session` | Session name (env WDIO_SESSION, default "default") | | `--json` | Print one JSON object (env WDIO_SESSION_JSON=1) | -| `--timeout` | Request timeout in ms | +| `--timeout` | Request timeout in ms (capped at 60000 except for wait) | | `-q, --quiet` | Print nothing on success except requested data | | `--color` | Use --no-color to disable colors | @@ -355,7 +355,7 @@ See also: [`find`](#find), [`diff`](#diff), [`screenshot`](#screenshot). Read the page text as Markdown. Applies to web. -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. +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. ```sh npx wdio session read @@ -367,6 +367,7 @@ npx wdio session read | --- | --- | | `--scope ` | Only read below this ref or selector | | `--max-chars ` | Print up to this many characters (default 6000) | +| `--offset ` | Start at this character of the text, for the next part of a long page | **Examples** @@ -405,6 +406,7 @@ npx wdio session find | `-C, --context ` | Lines of context before and after instead of the surrounding node | | `-A, --after-context ` | Lines of context after each match | | `-B, --before-context ` | Lines of context before each match | +| `--offset ` | Skip this many matches, for the next ones when the output is cut | **Examples**