feat: add native mobile swipe (#14014)

* feat: add native mobile swipe

- add new swipe for native apps method
- add docs
- update `scrollIntoView` to make it depended on the `swipe`
- update UTs for `scrollIntoView`
- update and fix formatter and  template

@TODO: add UT's

* feat: add duration

- add ut's
- update docs

* chore: remove not used const

* chore: update docs

* chore: fixes after feedback
This commit is contained in:
Wim Selles 2024-12-30 18:43:33 +01:00 committed by GitHub
parent a35db04fab
commit ba09aebe8e
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
11 changed files with 754 additions and 305 deletions

View file

@ -45,4 +45,5 @@ export * from './browser/waitUntil.js'
/**
* Add it to the Browser scope, but not to the docs by providing a different folder
*/
export * from './mobile/swipe.js'
export * from './mobile/tap.js'

View file

@ -13,9 +13,7 @@ const log = logger('webdriverio')
*
* :::info
*
* Scrolling for Mobile Native Apps is done based on native mobile gestures. It is only supported for the following drivers:
* - [appium-uiautomator2-driver](https://github.com/appium/appium-uiautomator2-driver/blob/master/docs/android-mobile-gestures.md#mobile-scrollgesture) for Android
* - [appium-xcuitest-driver](https://appium.github.io/appium-xcuitest-driver/latest/reference/execute-methods/#mobile-scroll) for iOS
* Scrolling for Mobile Native Apps is done based on the mobile `swipe` command.
*
* :::
*
@ -37,18 +35,26 @@ const log = logger('webdriverio')
// scroll to a specific element in the default scrollable element for Android or iOS for a maximum of 10 scrolls
await elem.scrollIntoView();
// Scroll to the left in the scrollable element called '#scrollable' for a maximum of 5 scrolls
await elem.scrollIntoView({ direction: 'left', maxScrolls: 5, scrollableElement: $('#scrollable') });
await elem.scrollIntoView({
direction: 'left',
maxScrolls: 5,
scrollableElement: $('#scrollable')
});
});
* </example>
*
* @alias element.scrollIntoView
* @param {object|boolean=} options options for `Element.scrollIntoView()`. Default for desktop/mobile web: <br/> `{ block: 'start', inline: 'nearest' }` <br /> Default for Mobile Native App <br /> `{ maxScrolls: 10, scrollDirection: 'down' }`
* @rowInfo Desktop/Mobile Web Only
* @param {string=} options.behavior See [MDN Reference](https://developer.mozilla.org/en-US/docs/Web/API/Element/scrollIntoView). <br /><strong>WEB-ONLY</strong> (Desktop/Mobile)
* @param {string=} options.block See [MDN Reference](https://developer.mozilla.org/en-US/docs/Web/API/Element/scrollIntoView). <br /><strong>WEB-ONLY</strong> (Desktop/Mobile)
* @param {string=} options.inline See [MDN Reference](https://developer.mozilla.org/en-US/docs/Web/API/Element/scrollIntoView). <br /><strong>WEB-ONLY</strong> (Desktop/Mobile)
* @param {string=} options.direction Can be one of `down`, `up`, `left` or `right`, default is `down`. <br /><strong>MOBILE-NATIVE-APP-ONLY</strong>
* @rowInfo Mobile Native App Only
* @param {string=} options.direction Can be one of `down`, `up`, `left` or `right`, default is `up`. <br /><strong>MOBILE-NATIVE-APP-ONLY</strong>
* @param {number=} options.maxScrolls The max amount of scrolls until it will stop searching for the element, default is `10`. <br /><strong>MOBILE-NATIVE-APP-ONLY</strong>
* @param {number=} options.duration The duration in milliseconds for the swipe. Default is `1500` ms. The lower the value, the faster the swipe.<br /><strong>MOBILE-NATIVE-APP-ONLY</strong>
* @param {Element=} options.scrollableElement Element that is used to scroll within. If no element is provided it will use the following selector for iOS `-ios predicate string:type == "XCUIElementTypeApplication"` and the following for Android `//android.widget.ScrollView'`. If more elements match the default selector, then by default it will pick the first matching element. <br /> <strong>MOBILE-NATIVE-APP-ONLY</strong>
* @param {number=} options.percent The percentage of the (default) scrollable element to swipe. This is a value between 0 and 1. Default is `0.95`.<br /><strong>NEVER</strong> swipe from the exact top|bottom|left|right of the screen, you might trigger for example the notification bar or other OS/App features which can lead to unexpected results.<br /> <strong>MOBILE-NATIVE-APP-ONLY</strong>
* @uses protocol/execute
* @type utility
*
@ -63,7 +69,7 @@ export async function scrollIntoView (
* Appium does not support the "wheel" action
*/
if (browser.isMobile) {
if (await browser.getContext() === 'NATIVE_APP') {
if (await browser.isNativeContext) {
return nativeMobileScrollIntoView({
browser,
element: this,
@ -139,51 +145,20 @@ type MobileScrollUntilVisibleOptions = {
browser: WebdriverIO.Browser;
element: WebdriverIO.Element;
maxScrolls: number;
scrollDirection: MobileScrollDirection;
scrollableElement: WebdriverIO.Element | null;
direction: MobileScrollDirection;
scrollableElement?: WebdriverIO.Element | null;
duration?: number;
percent?: number;
};
async function getScrollableElement({
browser,
options
}: {
browser: WebdriverIO.Browser,
options?: MobileScrollIntoViewOptions
}): Promise<WebdriverIO.Element | null> {
if (options?.scrollableElement) {
return options?.scrollableElement
}
const defaultAndroidSelector = '//android.widget.ScrollView'
const defaultIosSelector = '-ios predicate string:type == "XCUIElementTypeApplication"'
const selector = browser.isIOS
? // For iOS, we need to find the application element, if we can't find it, we should throw an error
defaultIosSelector
: // There is always a scrollview for Android or, if this fails we should throw an error
defaultAndroidSelector
// Not sure why we need to do this, but it seems to be necessary
const scrollableElements = (await browser.$$(
selector
)) as unknown as WebdriverIO.Element[]
if (scrollableElements.length > 0) {
return scrollableElements[0]
}
throw new Error(
`Default scrollable element '${browser.isIOS ? defaultIosSelector : defaultAndroidSelector}' was not found. Our advice is to provide a scrollable element like this:
await elem.scrollIntoView({ scrollableElement: $('#scrollable') });
`
)
}
async function mobileScrollUntilVisible({
browser,
direction,
duration,
element,
scrollableElement,
maxScrolls,
scrollDirection,
percent,
scrollableElement,
}: MobileScrollUntilVisibleOptions): Promise<{ hasScrolled: boolean; isVisible: boolean; }> {
let isVisible = false
let hasScrolled = false
@ -198,20 +173,13 @@ async function mobileScrollUntilVisible({
if (isVisible) {break}
if (browser.isIOS) {
await browser.execute('mobile: scroll', {
elementId: (await scrollableElement)?.elementId,
direction: scrollDirection,
})
hasScrolled = true
} else {
await browser.execute('mobile: scrollGesture', {
elementId: (await scrollableElement)?.elementId,
direction: scrollDirection,
percent: 0.5,
})
hasScrolled = true
}
await browser.swipe({
direction,
...(duration ? { duration } : {}),
...(percent ? { percent } : {}),
...(scrollableElement ? { scrollableElement } : {}),
})
hasScrolled = true
scrolls++
}
@ -230,19 +198,20 @@ async function nativeMobileScrollIntoView({
}) {
const defaultOptions = {
maxScrolls: 10,
scrollDirection: MobileScrollDirection.Down,
direction: MobileScrollDirection.Up,
}
const mobileOptions = {
...defaultOptions,
...(options || {}),
}
const scrollableElement = await getScrollableElement({ browser, options: mobileOptions })
const { hasScrolled, isVisible } = await mobileScrollUntilVisible({
browser,
element,
maxScrolls: mobileOptions.maxScrolls,
scrollDirection: mobileOptions.scrollDirection,
scrollableElement,
direction: mobileOptions.direction,
...(mobileOptions?.duration ? { duration: mobileOptions.duration } : {}),
...(mobileOptions?.percent ? { percent: mobileOptions.percent } : {}),
...(mobileOptions?.scrollableElement ? { scrollableElement: mobileOptions.scrollableElement } : {}),
})
if (hasScrolled && isVisible) {
@ -254,7 +223,7 @@ async function nativeMobileScrollIntoView({
return
}
throw new Error(`Element not found within scroll limit of ${mobileOptions.maxScrolls} scrolls by scrolling "${mobileOptions.scrollDirection}". ` +
throw new Error(`Element not found within scroll limit of ${mobileOptions.maxScrolls} scrolls by scrolling "${mobileOptions.direction}". ` +
`Are you sure the element is within the scrollable element or the direction is correct? You can change the scrollable element or direction like this:
await elem.scrollIntoView({

View file

@ -4,9 +4,7 @@
*
* :::info
*
* Scrolling for Mobile Native Apps is done based on native mobile gestures. It is only supported for the following drivers:
* - [appium-uiautomator2-driver](https://github.com/appium/appium-uiautomator2-driver/blob/master/docs/android-mobile-gestures.md#mobile-scrollgesture) for Android
* - [appium-xcuitest-driver](https://appium.github.io/appium-xcuitest-driver/latest/reference/execute-methods/#mobile-scroll) for iOS
* Scrolling for Mobile Native Apps is done based on the mobile `swipe` command.
*
* :::
*
@ -28,21 +26,28 @@
// scroll to a specific element in the default scrollable element for Android or iOS for a maximum of 10 scrolls
await elem.scrollIntoView();
// Scroll to the left in the scrollable element called '#scrollable' for a maximum of 5 scrolls
await elem.scrollIntoView({ direction: 'left', maxScrolls: 5, scrollableElement: $('#scrollable') });
await elem.scrollIntoView({
direction: 'left',
maxScrolls: 5,
scrollableElement: $('#scrollable')
});
});
* </example>
*
* @alias element.scrollIntoView
* @param {object|boolean=} options options for `Element.scrollIntoView()`. Default for desktop/mobile web: <br/> `{ block: 'start', inline: 'nearest' }` <br /> Default for Mobile Native App <br /> `{ maxScrolls: 10, scrollDirection: 'down' }`
* @rowInfo Desktop/Mobile Web Only
* @param {string=} options.behavior See [MDN Reference](https://developer.mozilla.org/en-US/docs/Web/API/Element/scrollIntoView). <br /><strong>WEB-ONLY</strong> (Desktop/Mobile)
* @param {string=} options.block See [MDN Reference](https://developer.mozilla.org/en-US/docs/Web/API/Element/scrollIntoView). <br /><strong>WEB-ONLY</strong> (Desktop/Mobile)
* @param {string=} options.inline See [MDN Reference](https://developer.mozilla.org/en-US/docs/Web/API/Element/scrollIntoView). <br /><strong>WEB-ONLY</strong> (Desktop/Mobile)
* @param {string=} options.direction Can be one of `down`, `up`, `left` or `right`, default is `down`. <br /><strong>MOBILE-NATIVE-APP-ONLY</strong>
* @rowInfo Mobile Native App Only
* @param {string=} options.direction Can be one of `down`, `up`, `left` or `right`, default is `up`. <br /><strong>MOBILE-NATIVE-APP-ONLY</strong>
* @param {number=} options.maxScrolls The max amount of scrolls until it will stop searching for the element, default is `10`. <br /><strong>MOBILE-NATIVE-APP-ONLY</strong>
* @param {number=} options.duration The duration in milliseconds for the swipe. Default is `1500` ms. The lower the value, the faster the swipe.<br /><strong>MOBILE-NATIVE-APP-ONLY</strong>
* @param {Element=} options.scrollableElement Element that is used to scroll within. If no element is provided it will use the following selector for iOS `-ios predicate string:type == "XCUIElementTypeApplication"` and the following for Android `//android.widget.ScrollView'`. If more elements match the default selector, then by default it will pick the first matching element. <br /> <strong>MOBILE-NATIVE-APP-ONLY</strong>
* @mobileElement
* @param {number=} options.percent The percentage of the (default) scrollable element to swipe. This is a value between 0 and 1. Default is `0.95`.<br /><strong>NEVER</strong> swipe from the exact top|bottom|left|right of the screen, you might trigger for example the notification bar or other OS/App features which can lead to unexpected results.<br /> <strong>MOBILE-NATIVE-APP-ONLY</strong>
* @uses protocol/execute
* @type utility
*
* @skipUsage
*/
// actual implementation is located in packages/webdriverio/src/element/scrollIntoView.ts

View file

@ -0,0 +1,228 @@
import logger from '@wdio/logger'
import type { SwipeOptions, XY } from '../../types.js'
import { MobileScrollDirection } from '../../types.js'
const log = logger('webdriverio')
const SWIPE_DEFAULTS = {
DIRECTION: MobileScrollDirection.Up,
DURATION: 1500,
PERCENT: 0.95,
}
/**
*
* Swipe in a specific direction within viewport or element for Desktop/Mobile Web <strong>AND</strong> Mobile Native Apps.
*
* :::info
*
* Swiping for Mobile Native Apps is based on the W3C-actions protocol, simulating a finger press and movement.
* This is different from the [`mobile: scrollGesture`](https://github.com/appium/appium-uiautomator2-driver/blob/master/docs/android-mobile-gestures.md#mobile-scrollgesture) for Android
* or [`mobile: scroll`](https://appium.github.io/appium-xcuitest-driver/latest/reference/execute-methods/#mobile-scroll) for iOS command which is based on the Appium Driver protocol and is
* only available for mobile platforms in the NATIVE context.
*
* :::
*
* :::caution Swiping based on coordinates
*
* Avoid using `from` and `to` options unless absolutely necessary. These are device-specific and may not work consistently across devices.
* Use the `scrollableElement` option for reliable swipes within an element.
*
* :::
*
* <example>
:swipe.js
it('should execute a default swipe', async () => {
// Default will be a swipe from the bottom to the top, meaning it will swipe UP
await browser.swipe();
});
* </example>
*
* <example>
:swipe.with.options.js
it('should execute a swipe with options', async () => {
await browser.swipe({
direction: 'left', // Swipe from right to left
duration: 5000, // Last for 5 seconds
percent: 0.5, // Swipe 50% of the scrollableElement
scrollableElement: $('~carousel'), // The element to swipe within
})
});
* </example>
*
* @alias element.scrollIntoView
* @param {object|boolean=} options options for `Element.scrollIntoView()`. Default for desktop/mobile web: <br/> `{ block: 'start', inline: 'nearest' }` <br /> Default for Mobile Native App <br /> `{ maxScrolls: 10, scrollDirection: 'down' }`
* @param {string=} options.direction Can be one of `down`, `up`, `left` or `right`, default is `up`. <br /><strong>MOBILE-NATIVE-APP-ONLY</strong>
* @rowInfo Down ::<strong>Starting Point:</strong><br/>You place your finger towards the top of the screen.<br/><strong>Movement:</strong><br/>You slide your finger downwards towards the bottom of the screen.<br/><strong>Action:</strong><br/>This also varies by context:<br />- On the home screen or in applications, it typically scrolls the content upwards.<br />- From the top edge, it often opens the notifications panel or quick settings.<br />- In browsers or reading apps, it can be used to scroll through content.
* @rowInfo Left ::<strong>Starting Point:</strong><br/>You place your finger on the right side of the screen.<br/><strong>Movement:</strong><br/>You slide your finger horizontally to the left.><br/><strong>Action:</strong><br/>The response to this gesture depends on the application:<br />- It can move to the next item in a carousel or a set of images.<br />- In a navigation context, it might go back to the previous page or close the current view.<br />- On the home screen, it usually switches to the next virtual desktop or screen.
* @rowInfo Right ::<strong>Starting Point:</strong><br/>You place your finger on the left side of the screen.<br/><strong>Movement:</strong><br/>You slide your finger horizontally to the right.<br/><strong>Action:</strong><br/>Similar to swiping left, but in the opposite direction:<br />-- It often moves to the previous item in a carousel or gallery.<br />- Can be used to open side menus or navigation drawers in apps.<br />- On the home screen, it typically switches to the previous virtual desktop.
* @rowInfo Up ::<strong>Starting Point:</strong><br/>You place your finger towards the bottom of the screen.<br/><strong>Movement:</strong><br/>You slide your finger upwards towards the top of the screen.><br/><strong>Action:</strong><br/>Depending on the context, different actions can occur:<br />- On the home screen or in a list, this usually scrolls the content downwards.<br />- In a full-screen app, it might open additional options or the app drawer.<br />- On certain interfaces, it could trigger a 'refresh' action or open a search bar.
* @param {number=} options.duration The duration in milliseconds for the swipe. Default is `1500` ms. The lower the value, the faster the swipe.
* @param {Element=} options.scrollableElement Element that is used to swipe within. If no element is provided it will use the following selector for iOS `-ios predicate string:type == "XCUIElementTypeApplication"` and the following for Android `//android.widget.ScrollView'`. If more elements match the default selector, then by default it will pick the first matching element. <br /> <strong>MOBILE-NATIVE-APP-ONLY</strong>
* @param {number=} options.percent The percentage of the (default) scrollable element to swipe. This is a value between 0 and 1. Default is `0.95`.<br /><strong>NEVER</strong> swipe from the exact top|bottom|left|right of the screen, you might trigger for example the notification bar or other OS/App features which can lead to unexpected results.<br />This has no effect if `from` and `to` are provided.
* @rowInfo The below values <strong>ONLY</strong> have an effect if the `scrollableElement` is <strong>NOT</strong> provided, otherwise they are ignored.
* @param {object=} options.from The x and y coordinates of the start of the swipe. If a `scrollableElement` is provided, then these coordinates have no effect.
* @param {number=} options.from.x The x-coordinate of the start of the swipe.
* @param {number=} options.from.y The y-coordinate of the start of the swipe.
* @param {object=} options.to The x and y coordinates of the end of the swipe. If a `scrollableElement` is provided, then these coordinates have no effect.
* @param {number=} options.to.x The x-coordinate of the end of the swipe.
* @param {number=} options.to.y The y-coordinate of the end of the swipe.
* @uses protocol/execute
* @type utility
* @skipUsage
*/
export async function swipe (
this: WebdriverIO.Browser,
options?: SwipeOptions
): Promise<void|unknown> {
const browser = this
if (!browser.isNativeContext) {
throw new Error('The swipe command is only available for mobile platforms in the NATIVE context.')
}
let { scrollableElement, from, to } = options || {}
if (scrollableElement && (from || to)) {
log.warn('`scrollableElement` is provided, so `from` and `to` will be ignored.')
}
if (!from || !to) {
scrollableElement = scrollableElement || await getScrollableElement(browser);
({ from, to } = await calculateFromTo({
browser,
direction: options?.direction || SWIPE_DEFAULTS.DIRECTION,
percentage: options?.percent,
scrollableElement,
}))
}
return w3cSwipe({ browser, duration: options?.duration || SWIPE_DEFAULTS.DURATION, from, to })
}
async function calculateFromTo({
browser,
direction,
percentage,
scrollableElement
}: {
browser: WebdriverIO.Browser,
direction: MobileScrollDirection,
percentage?: number,
scrollableElement: WebdriverIO.Element
}): Promise<{ from: XY, to: XY }> {
// 1. Determine the percentage of the scrollable container to be scrolled
// The swipe percentage is the percentage of the scrollable container that should be scrolled
// Never swipe from the exact top|bottom|left|right of the screen, you might trigger the notification bar or other OS/App features
let swipePercentage = SWIPE_DEFAULTS.PERCENT
if (percentage !== undefined) {
if (isNaN(percentage)) {
log.warn('The percentage to swipe should be a number.')
} else if (percentage < 0 || percentage > 1) {
log.warn('The percentage to swipe should be a number between 0 and 1.')
} else {
swipePercentage = percentage
}
}
// 2. Determine the swipe coordinates
// When we get the element rect we get the position of the element on the screen based on the
// - x (position from the left of the screen)
// - y (position from the top of the screen)
// - width (width of the element)
// - height (height of the element)
// We can use this to calculate the position of the swipe by determining the
// - top
// - right
// - bottom
// - left
// of the element. These positions will contain the x and y coordinates on where to put the finger
const { x, y, width, height } = await browser.getElementRect(scrollableElement?.elementId)
// It's always advisable to swipe from the center of the element.
const scrollRectangles = {
top: { x: Math.round(x + width / 2), y: Math.round(y + height - height * swipePercentage) },
right: { x: Math.round(x + width * swipePercentage), y: Math.round(y + height / 2) },
bottom: { x: Math.round(x + width / 2), y: Math.round(y + height * swipePercentage) },
left: { x: Math.round(x + width - width * swipePercentage), y: Math.round(y + height / 2) },
}
// 3. Swipe in the given direction
let from: XY
let to: XY
switch (direction) {
case MobileScrollDirection.Down:
from = scrollRectangles.top
to = scrollRectangles.bottom
break
case MobileScrollDirection.Left:
from = scrollRectangles.right
to= scrollRectangles.left
break
case MobileScrollDirection.Right:
from = scrollRectangles.left
to = scrollRectangles.right
break
case MobileScrollDirection.Up:
from = scrollRectangles.bottom
to = scrollRectangles.top
break
default:
throw new Error(`Unknown direction: ${direction}`)
}
return { from, to }
}
async function getScrollableElement( browser: WebdriverIO.Browser): Promise<WebdriverIO.Element> {
const defaultAndroidSelector = '//android.widget.ScrollView'
const defaultIosSelector = '-ios predicate string:type == "XCUIElementTypeApplication"'
const selector = browser.isIOS
? // For iOS, we need to find the application element, if we can't find it, we should throw an error
defaultIosSelector
: // There is always a scrollview for Android or, if this fails we should throw an error
defaultAndroidSelector
// Not sure why we need to do this, but it seems to be necessary
const scrollableElements = (await browser.$$(
selector
)) as unknown as WebdriverIO.Element[]
if (scrollableElements.length > 0) {
return scrollableElements[0]
}
throw new Error(
`Default scrollable element '${browser.isIOS ? defaultIosSelector : defaultAndroidSelector}' was not found. Our advice is to provide a scrollable element like this:
await browser.swipe({ scrollableElement: $('#scrollable') });
`
)
}
async function w3cSwipe({ browser, duration, from, to }: {browser: WebdriverIO.Browser, duration: number, from: XY, to: XY}) {
await browser
// a. Create the event
.action('pointer', {
parameters: { pointerType: browser.isMobile ? 'touch' : 'mouse' }
})
// b. Move finger into start position
.move(from.x, from.y) // This can also be written as .move({ x:from.x, y:from.y }) which allows you to add more options
// c. Finger comes down into contact with screen
.down()
// d. Pause for a little bit
.pause(10)
// e. Finger moves to end position
// IMPORTANT. The default duration, if you don't provide it, is 100ms. This means that the movement will be so fast that it:
// - might not be registered
// - might not have the correct result on longer movements.
// Short durations will move elements on the screen over longer move coordinates very fast.
.move({ duration: duration, x: to.x, y: to.y })
// f. Finger gets up, off the screen
.up()
// g. Perform the action
.perform()
// Add a pause, just to make sure the swipe is done
// eslint-disable-next-line wdio/no-pause
return browser.pause(500)
}

View file

@ -531,10 +531,22 @@ export enum MobileScrollDirection {
Right = 'right',
}
export type MobileScrollIntoViewOptions = {
export type XY = {
x: number,
y: number
}
export type SwipeOptions = {
direction?: MobileScrollDirection;
duration?: number;
from?: XY;
percent?: number;
scrollableElement?: WebdriverIO.Element ;
to?: XY;
}
export type MobileScrollIntoViewOptions = SwipeOptions & {
maxScrolls?: number;
scrollableElement?: WebdriverIO.Element;
}
export interface CustomScrollIntoViewOptions extends ScrollIntoViewOptions, MobileScrollIntoViewOptions {

View file

@ -100,37 +100,8 @@ exports[`scrollIntoView test > desktop > scrolls element when using boolean scro
}
`;
exports[`scrollIntoView test > mobile Android app > does not find the default scroll element 1`] = `
"Default scrollable element '//android.widget.ScrollView' was not found. Our advice is to provide a scrollable element like this:
await elem.scrollIntoView({ scrollableElement: $('#scrollable') });
"
`;
exports[`scrollIntoView test > mobile Android app > scrolls by default the element to the top 1`] = `
{
"args": [
{
"direction": "down",
"elementId": "scrollable-element-id",
"percent": 0.5,
},
],
"script": "mobile: scrollGesture",
}
`;
exports[`scrollIntoView test > mobile ios app > does not find the default scroll element 1`] = `
"Default scrollable element '-ios predicate string:type == "XCUIElementTypeApplication"' was not found. Our advice is to provide a scrollable element like this:
await elem.scrollIntoView({ scrollableElement: $('#scrollable') });
"
`;
exports[`scrollIntoView test > mobile ios app > does not find the element after max 5 scrolls 1`] = `
"Element not found within scroll limit of 5 scrolls by scrolling "down". Are you sure the element is within the scrollable element or the direction is correct? You can change the scrollable element or direction like this:
exports[`scrollIntoView test > mobile native app > does not find the element after max 5 scrolls 1`] = `
"Element not found within scroll limit of 5 scrolls by scrolling "up". Are you sure the element is within the scrollable element or the direction is correct? You can change the scrollable element or direction like this:
await elem.scrollIntoView({
direction: 'left' // possible options are: 'up|down|left|right'
@ -139,29 +110,3 @@ await elem.scrollIntoView({
"
`;
exports[`scrollIntoView test > mobile ios app > scrolls by default the element to the top 1`] = `
{
"args": [
{
"direction": "down",
"elementId": "scrollable-element-id",
},
],
"script": "mobile: scroll",
}
`;
exports[`scrollIntoView test > mobile ios app > scrolls in a provided scroll-container to the left 1`] = `"{"using":"css selector","value":"#scrollContainer"}"`;
exports[`scrollIntoView test > mobile ios app > scrolls in a provided scroll-container to the left 2`] = `
{
"args": [
{
"direction": "left",
"elementId": "some-elem-123",
},
],
"script": "mobile: scroll",
}
`;

View file

@ -25,6 +25,7 @@ describe('scrollIntoView test', () => {
browserName: 'foobar',
},
})
// @ts-expect-error
elem = await browser.$('#foo')
vi.spyOn(browser, 'getWindowSize').mockResolvedValue({ height: 800, width: 600 })
vi.spyOn(browser, 'getElementRect').mockImplementation(() =>
@ -90,6 +91,7 @@ describe('scrollIntoView test', () => {
mobileMode: true
} as any
})
// @ts-expect-error
elem = await browser.$('#foo')
// @ts-expect-error mock feature
elem.elementId = { scrollIntoView: 'mockFunction' }
@ -102,9 +104,8 @@ describe('scrollIntoView test', () => {
it('scrolls by default the element to the top', async () => {
await elem.scrollIntoView()
const { calls } = vi.mocked(fetch).mock
expect(calls).toHaveLength(2)
const [[contextCallUrl], [executeCallUrl, executeCallOptions]] = calls as any
expect(contextCallUrl.pathname).toEqual('/session/foobar-123/context')
expect(calls).toHaveLength(1)
const [[executeCallUrl, executeCallOptions]] = calls as any
expect(executeCallUrl.pathname).toEqual('/session/foobar-123/execute/sync')
expect(JSON.parse(executeCallOptions.body).script).toEqual(
expect.stringContaining('return ((elem, options2) => elem.scrollIntoView(options2)).apply(null, arguments)'))
@ -115,9 +116,8 @@ describe('scrollIntoView test', () => {
it('scrolls element when using boolean scroll options', async () => {
await elem.scrollIntoView(true)
const { calls } = vi.mocked(fetch).mock
expect(calls).toHaveLength(2)
const [[contextCallUrl], [executeCallUrl, executeCallOptions]] = calls as any
expect(contextCallUrl.pathname).toEqual('/session/foobar-123/context')
expect(calls).toHaveLength(1)
const [[executeCallUrl, executeCallOptions]] = calls as any
expect(executeCallUrl.pathname).toEqual('/session/foobar-123/execute/sync')
expect(JSON.parse(executeCallOptions.body).script).toEqual(
expect.stringContaining('return ((elem, options2) => elem.scrollIntoView(options2)).apply(null, arguments)'))
@ -128,9 +128,8 @@ describe('scrollIntoView test', () => {
it('scrolls element using scroll into view options', async () => {
await elem.scrollIntoView({ block: 'end', inline: 'center' })
const { calls } = vi.mocked(fetch).mock
expect(calls).toHaveLength(2)
const [[contextCallUrl], [executeCallUrl, executeCallOptions]] = calls as any
expect(contextCallUrl.pathname).toEqual('/session/foobar-123/context')
expect(calls).toHaveLength(1)
const [[executeCallUrl, executeCallOptions]] = calls as any
expect(executeCallUrl.pathname).toEqual('/session/foobar-123/execute/sync')
expect(JSON.parse(executeCallOptions.body).script).toEqual(
expect.stringContaining('return ((elem, options2) => elem.scrollIntoView(options2)).apply(null, arguments)'))
@ -139,17 +138,19 @@ describe('scrollIntoView test', () => {
})
})
describe('mobile ios app', () => {
describe('mobile native app', () => {
beforeAll(async () => {
browser = await remote({
capabilities: {
mobileMode: true,
nativeAppMode: true,
platformName: 'iOS',
platformVersion: '16',
deviceName: 'iphone',
app: 'foo.ipa',
} as any
})
// @ts-expect-error
elem = await browser.$('#foo')
// @ts-expect-error mock feature
elem.elementId = { scrollIntoView: 'mockFunction' }
@ -157,7 +158,6 @@ describe('scrollIntoView test', () => {
beforeEach(() => {
vi.mocked(fetch).mockClear()
vi.spyOn(browser, 'getContext').mockResolvedValue('NATIVE_APP')
vi.spyOn(browser, 'pause').mockResolvedValue(undefined)
vi.spyOn(browser, '$$')
})
@ -172,11 +172,6 @@ describe('scrollIntoView test', () => {
await elem.scrollIntoView()
// We're in the native context and will start scrolling
expect(browser.getContext).toBeCalledTimes(1)
// getScrollableElement is called and returns the default iOS scrollable element
const [[selector]] = (browser.$$ as Mock).mock.calls
expect(selector).toEqual(defaultIOSSelector)
// call the mobileScrollUntilVisible but no scroll is needed
const { calls } = vi.mocked(fetch).mock
expect(calls).toHaveLength(0)
@ -188,79 +183,42 @@ describe('scrollIntoView test', () => {
elementId: 'scrollable-element-id',
isDisplayed: vi.fn().mockResolvedValue(true),
}])
vi.spyOn(elem, 'isDisplayed').mockResolvedValueOnce(false).mockResolvedValueOnce(true)
const isDisplayedSpy = vi.spyOn(elem, 'isDisplayed').mockResolvedValueOnce(false).mockResolvedValueOnce(true)
const swipeSpy = vi.spyOn(browser, 'swipe').mockResolvedValue(undefined)
await elem.scrollIntoView()
// We're in the native context and will start scrolling
expect(browser.getContext).toBeCalledTimes(1)
// getScrollableElement is called and returns the default iOS scrollable element
const [[selector]] = (browser.$$ as Mock).mock.calls
expect(selector).toEqual(defaultIOSSelector)
// call the mobileScrollUntilVisible
const { calls } = vi.mocked(fetch).mock
const [[executeCallUrl, executeCallOptions]] = calls as any
expect(calls).toHaveLength(1)
expect(executeCallUrl.pathname).toEqual('/session/foobar-123/execute/sync')
expect(JSON.parse(executeCallOptions.body)).toMatchSnapshot()
// browser.pause is called with the default value of 1000ms
expect(browser.pause).toBeCalledTimes(1)
expect(browser.pause).toBeCalledWith(1000)
expect(isDisplayedSpy).toBeCalledTimes(2)
expect(swipeSpy).toBeCalledTimes(1)
expect(swipeSpy).toBeCalledWith({ direction: 'up' })
swipeSpy.mockRestore()
isDisplayedSpy.mockRestore()
})
it('scrolls in a provided scroll-container to the left', async () => {
it('scrolls to the left with some options', async () => {
// Set some spy values
(browser.$$ as Mock).mockResolvedValue([{
elementId: 'scrollable-element-id',
isDisplayed: vi.fn().mockResolvedValue(true),
}])
vi.spyOn(elem, 'isDisplayed').mockResolvedValueOnce(false).mockResolvedValueOnce(true)
const isDisplayedSpy = vi.spyOn(elem, 'isDisplayed').mockResolvedValueOnce(false).mockResolvedValueOnce(true)
const swipeSpy = vi.spyOn(browser, 'swipe').mockResolvedValue(undefined)
await elem.scrollIntoView({
scrollableElement: browser.$('#scrollContainer'),
scrollDirection: 'left',
// @ts-expect-error
direction: 'left',
duration: 3000,
percent: 0.5,
})
// We're in the native context and will start scrolling
expect(browser.getContext).toBeCalledTimes(1)
// getScrollableElement is called but we have a provided scrollable element so it should not return the default iOS scrollable element
expect((browser.$$ as Mock).mock.calls).toHaveLength(0)
// call the mobileScrollUntilVisible
const { calls } = vi.mocked(fetch).mock
const [
[scrollableElementCallUrl, scrollableElementCallOptions],
[executeCallUrl, executeCallOptions]] = calls as any
expect(calls).toHaveLength(2)
// First check if the scrollable element is called
expect(scrollableElementCallUrl.pathname).toEqual('/session/foobar-123/element')
expect(scrollableElementCallOptions.body).toMatchSnapshot()
expect(executeCallUrl.pathname).toEqual('/session/foobar-123/execute/sync')
expect(JSON.parse(executeCallOptions.body)).toMatchSnapshot()
// browser.pause is called with the default value of 1000ms
expect(browser.pause).toBeCalledTimes(1)
expect(browser.pause).toBeCalledWith(1000)
})
it('does not find the default scroll element', async () => {
// Set some spy values
(browser.$$ as Mock).mockResolvedValue([])
vi.spyOn(elem, 'isDisplayed').mockResolvedValue(false)
try {
await elem.scrollIntoView()
} catch (err: any) {
// We're in the native context and will start scrolling
expect(browser.getContext).toBeCalledTimes(1)
// getScrollableElement is called and returns the default iOS scrollable element
const [[selector]] = (browser.$$ as Mock).mock.calls
expect(selector).toEqual(defaultIOSSelector)
expect((browser.$$ as Mock).mock.calls).toHaveLength(1)
// The mobileScrollUntilVisible is not called, meaning also the internal methods are also not called
const { calls } = vi.mocked(fetch).mock
console.log('err.message = ', err.message)
expect(calls).toHaveLength(0)
expect(err.message).toMatchSnapshot()
}
expect(isDisplayedSpy).toBeCalledTimes(2)
expect(swipeSpy).toBeCalledTimes(1)
expect(swipeSpy).toBeCalledWith({ direction: 'left', duration: 3000, percent: 0.5 })
swipeSpy.mockRestore()
isDisplayedSpy.mockRestore()
})
it('does not find the element after max 5 scrolls', async () => {
@ -269,111 +227,15 @@ describe('scrollIntoView test', () => {
elementId: 'scrollable-element-id',
isDisplayed: vi.fn().mockResolvedValue(true),
}])
vi.spyOn(elem, 'isDisplayed').mockResolvedValue(false)
const isDisplayedSpy = vi.spyOn(elem, 'isDisplayed').mockResolvedValue(false)
const swipeSpy = vi.spyOn(browser, 'swipe').mockResolvedValue(undefined)
try {
await elem.scrollIntoView({ maxScrolls: 5 })
} catch (err: any) {
// We're in the native context and will start scrolling
expect(browser.getContext).toBeCalledTimes(1)
// getScrollableElement is called and returns the default iOS scrollable element
const [[selector]] = (browser.$$ as Mock).mock.calls
expect(selector).toEqual(defaultIOSSelector)
// call the mobileScrollUntilVisible 5 times for the max scrolls
const { calls } = vi.mocked(fetch).mock
expect(calls).toHaveLength(5)
expect(err.message).toMatchSnapshot()
}
})
})
describe('mobile Android app', () => {
beforeAll(async () => {
browser = await remote({
capabilities: {
mobileMode: true,
platformName: 'Android',
platformVersion: '14',
deviceName: 'Samsung',
app: 'foo.apk',
} as any
})
elem = await browser.$('#foo')
// @ts-expect-error mock feature
elem.elementId = { scrollIntoView: 'mockFunction' }
})
beforeEach(() => {
vi.mocked(fetch).mockClear()
vi.spyOn(browser, 'getContext').mockResolvedValue('NATIVE_APP')
vi.spyOn(browser, 'pause').mockResolvedValue(undefined)
vi.spyOn(browser, '$$')
})
it('no scroll is needed', async () => {
// Set some spy values
(browser.$$ as Mock).mockResolvedValue([{
elementId: 'scrollable-element-id',
isDisplayed: vi.fn().mockResolvedValue(true),
}])
vi.spyOn(elem, 'isDisplayed').mockResolvedValueOnce(true)
await elem.scrollIntoView()
// We're in the native context and will start scrolling
expect(browser.getContext).toBeCalledTimes(1)
// getScrollableElement is called and returns the default iOS scrollable element
const [[selector]] = (browser.$$ as Mock).mock.calls
expect(selector).toEqual(defaultAndroidSelector)
// call the mobileScrollUntilVisible but no scroll is needed
const { calls } = vi.mocked(fetch).mock
expect(calls).toHaveLength(0)
})
it('scrolls by default the element to the top', async () => {
// Set some spy values
(browser.$$ as Mock).mockResolvedValue([{
elementId: 'scrollable-element-id',
isDisplayed: vi.fn().mockResolvedValue(true),
}])
vi.spyOn(elem, 'isDisplayed').mockResolvedValueOnce(false).mockResolvedValueOnce(true)
await elem.scrollIntoView()
// We're in the native context and will start scrolling
expect(browser.getContext).toBeCalledTimes(1)
// getScrollableElement is called and returns the default iOS scrollable element
const [[selector]] = (browser.$$ as Mock).mock.calls
expect(selector).toEqual(defaultAndroidSelector)
// call the mobileScrollUntilVisible
const { calls } = vi.mocked(fetch).mock
const [[executeCallUrl, executeCallOptions]] = calls as any
expect(calls).toHaveLength(1)
expect(executeCallUrl.pathname).toEqual('/session/foobar-123/execute/sync')
expect(JSON.parse(executeCallOptions.body)).toMatchSnapshot()
// browser.pause is called with the default value of 1000ms
expect(browser.pause).toBeCalledTimes(1)
expect(browser.pause).toBeCalledWith(1000)
})
it('does not find the default scroll element', async () => {
// Set some spy values
(browser.$$ as Mock).mockResolvedValue([])
vi.spyOn(elem, 'isDisplayed').mockResolvedValue(false)
try {
await elem.scrollIntoView()
} catch (err: any) {
// We're in the native context and will start scrolling
expect(browser.getContext).toBeCalledTimes(1)
// getScrollableElement is called and returns the default iOS scrollable element
const [[selector]] = (browser.$$ as Mock).mock.calls
expect(selector).toEqual(defaultAndroidSelector)
expect((browser.$$ as Mock).mock.calls).toHaveLength(1)
// The mobileScrollUntilVisible is not called, meaning also the internal methods are also not called
const { calls } = vi.mocked(fetch).mock
console.log('err.message = ', err.message)
expect(calls).toHaveLength(0)
expect(isDisplayedSpy).toBeCalledTimes(5)
expect(swipeSpy).toBeCalledTimes(5)
expect(err.message).toMatchSnapshot()
}
})

View file

@ -0,0 +1,253 @@
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
exports[`swipe test > does not find the default scroll element for Android and throws an error 1`] = `
[Error: Default scrollable element '//android.widget.ScrollView' was not found. Our advice is to provide a scrollable element like this:
await browser.swipe({ scrollableElement: $('#scrollable') });
]
`;
exports[`swipe test > should be able to swipe based on provided coordinates 1`] = `
{
"actions": [
{
"button": 0,
"duration": 100,
"origin": "viewport",
"type": "pointerMove",
"x": 10,
"y": 20,
},
{
"button": 0,
"type": "pointerDown",
},
{
"duration": 10,
"type": "pause",
},
{
"button": 0,
"duration": 1500,
"origin": "viewport",
"type": "pointerMove",
"x": 30,
"y": 40,
},
{
"button": 0,
"type": "pointerUp",
},
],
"id": "action4",
"parameters": {
"pointerType": "touch",
},
"type": "pointer",
}
`;
exports[`swipe test > should call a swipe down command with 25% for a scrollable element 1`] = `
{
"actions": [
{
"button": 0,
"duration": 100,
"origin": "viewport",
"type": "pointerMove",
"x": 250,
"y": 500,
},
{
"button": 0,
"type": "pointerDown",
},
{
"duration": 10,
"type": "pause",
},
{
"button": 0,
"duration": 1500,
"origin": "viewport",
"type": "pointerMove",
"x": 250,
"y": 300,
},
{
"button": 0,
"type": "pointerUp",
},
],
"id": "action8",
"parameters": {
"pointerType": "touch",
},
"type": "pointer",
}
`;
exports[`swipe test > should call a swipe down command with duration 5000 for a scrollable element 1`] = `
{
"actions": [
{
"button": 0,
"duration": 100,
"origin": "viewport",
"type": "pointerMove",
"x": 250,
"y": 580,
},
{
"button": 0,
"type": "pointerDown",
},
{
"duration": 10,
"type": "pause",
},
{
"button": 0,
"duration": 5000,
"origin": "viewport",
"type": "pointerMove",
"x": 250,
"y": 220,
},
{
"button": 0,
"type": "pointerUp",
},
],
"id": "action12",
"parameters": {
"pointerType": "touch",
},
"type": "pointer",
}
`;
exports[`swipe test > should call a swipe left command with 75% for a scrollable element 1`] = `
{
"actions": [
{
"button": 0,
"duration": 100,
"origin": "viewport",
"type": "pointerMove",
"x": 325,
"y": 400,
},
{
"button": 0,
"type": "pointerDown",
},
{
"duration": 10,
"type": "pause",
},
{
"button": 0,
"duration": 1500,
"origin": "viewport",
"type": "pointerMove",
"x": 175,
"y": 400,
},
{
"button": 0,
"type": "pointerUp",
},
],
"id": "action10",
"parameters": {
"pointerType": "touch",
},
"type": "pointer",
}
`;
exports[`swipe test > should call a swipe right command with 80% for a scrollable element 1`] = `
{
"actions": [
{
"button": 0,
"duration": 100,
"origin": "viewport",
"type": "pointerMove",
"x": 160,
"y": 400,
},
{
"button": 0,
"type": "pointerDown",
},
{
"duration": 10,
"type": "pause",
},
{
"button": 0,
"duration": 1500,
"origin": "viewport",
"type": "pointerMove",
"x": 340,
"y": 400,
},
{
"button": 0,
"type": "pointerUp",
},
],
"id": "action11",
"parameters": {
"pointerType": "touch",
},
"type": "pointer",
}
`;
exports[`swipe test > should call a swipe up command with 45% for a scrollable element 1`] = `
{
"actions": [
{
"button": 0,
"duration": 100,
"origin": "viewport",
"type": "pointerMove",
"x": 250,
"y": 380,
},
{
"button": 0,
"type": "pointerDown",
},
{
"duration": 10,
"type": "pause",
},
{
"button": 0,
"duration": 1500,
"origin": "viewport",
"type": "pointerMove",
"x": 250,
"y": 420,
},
{
"button": 0,
"type": "pointerUp",
},
],
"id": "action9",
"parameters": {
"pointerType": "touch",
},
"type": "pointer",
}
`;
exports[`swipe test > should throw an error a swipe command for direction foo 1`] = `[Error: Unknown direction: foo]`;
exports[`swipe test > should throw an error if the swipe command is called for a browser session 1`] = `[Error: The swipe command is only available for mobile platforms in the NATIVE context.]`;

View file

@ -0,0 +1,170 @@
import path from 'node:path'
import type { Mock } from 'vitest'
import { beforeEach, expect, describe, it, afterEach, vi, beforeAll } from 'vitest'
import logger from '@wdio/logger'
import { MobileScrollDirection, remote } from '../../../src/index.js'
vi.mock('fetch')
const log = logger('test')
vi.mock('@wdio/logger', () => import(path.join(process.cwd(), '__mocks__', '@wdio/logger')))
let browser: WebdriverIO.Browser
let elem: WebdriverIO.Element
let rectSpy
let logSpy
const defaultAndroidSelector = '//android.widget.ScrollView'
describe('swipe test', () => {
beforeEach(async () => {
browser = await remote({
baseUrl: 'http://foobar.com',
capabilities: {
browserName: 'foobar',
platformName: 'Android',
app: 'app',
mobileMode: true,
nativeAppMode: true,
} as any
})
// @ts-expect-error
elem = await browser.$('#scrollableElement')
rectSpy = vi.spyOn(browser, 'getElementRect').mockResolvedValue({ x: 100, y: 200, width: 300, height: 400 })
logSpy = vi.spyOn(log, 'warn')
vi.spyOn(browser, 'pause').mockResolvedValue(0)
vi.spyOn(browser, '$$')
})
it('should log a warning if swipe is called with a "scrollableElement" and "from"', async () => {
await browser.swipe({
scrollableElement: elem,
from: { x: 10, y: 20 },
})
expect(logSpy).toHaveBeenCalledWith('`scrollableElement` is provided, so `from` and `to` will be ignored.')
})
it('should log a warning if swipe is called with a "scrollableElement" and "to"', async () => {
await browser.swipe({
scrollableElement: elem,
to: { x: 10, y: 20 },
})
expect(logSpy).toHaveBeenCalledWith('`scrollableElement` is provided, so `from` and `to` will be ignored.')
})
it('should find the default scroll element for Android and can swipe', async () => {
(browser.$$ as Mock).mockResolvedValue([{
elementId: 'scrollable-android-element-id',
}])
await browser.swipe()
const [[selector]] = (browser.$$ as Mock).mock.calls
expect(selector).toEqual(defaultAndroidSelector)
})
it('does not find the default scroll element for Android and throws an error', async () => {
(browser.$$ as Mock).mockResolvedValue([])
await expect(browser.swipe()).rejects.toThrowErrorMatchingSnapshot()
})
it('should be able to swipe based on provided coordinates', async () => {
await browser.swipe({
from: { x: 10, y: 20 },
to: { x: 30, y: 40 },
})
expect(rectSpy).not.toBeCalled()
// @ts-expect-error mock implementation
expect(vi.mocked(fetch).mock.calls[2][0].pathname)
.toBe('/session/foobar-123/actions')
expect(JSON.parse(vi.mocked(fetch).mock.calls[2][1]?.body as any).actions[0])
.toMatchSnapshot()
})
it('should log a warning if swipe percentage is not a number', async () => {
await browser.swipe({
direction: 'down',
// @ts-expect-error invalid param
percent: 'bar',
scrollableElement: elem,
})
expect(logSpy).toHaveBeenCalledWith('The percentage to swipe should be a number.')
})
it('should log a warning if swipe percentage is lower than 0', async () => {
browser.swipe({
direction: 'down',
percent: -1,
scrollableElement: elem,
})
expect(logSpy).toHaveBeenCalledWith('The percentage to swipe should be a number between 0 and 1.')
})
it('should log a warning if swipe percentage is bigger than 1', async () => {
await browser.swipe({
direction: 'down',
percent: 98,
scrollableElement: elem,
})
expect(logSpy).toHaveBeenCalledWith('The percentage to swipe should be a number between 0 and 1.')
})
const percentages = [0.25, 0.45, 0.75, 0.8]
Object.values(MobileScrollDirection) .forEach((direction, index) => {
const percent = percentages[index % percentages.length]
it(`should call a swipe ${direction} command with ${percent * 100}% for a scrollable element`, async () => {
await browser.swipe({
direction,
scrollableElement: elem,
percent,
})
expect(rectSpy).toHaveBeenCalledOnce()
// @ts-expect-error mock implementation
expect(vi.mocked(fetch).mock.calls[2][0].pathname)
.toBe('/session/foobar-123/actions')
expect(JSON.parse(vi.mocked(fetch).mock.calls[2][1]?.body as any).actions[0])
.toMatchSnapshot()
})
})
it('should call a swipe down command with duration 5000 for a scrollable element', async () => {
await browser.swipe({
duration: 5000,
scrollableElement: elem,
})
expect(rectSpy).toHaveBeenCalledOnce()
// @ts-expect-error mock implementation
expect(vi.mocked(fetch).mock.calls[2][0].pathname)
.toBe('/session/foobar-123/actions')
expect(JSON.parse(vi.mocked(fetch).mock.calls[2][1]?.body as any).actions[0])
.toMatchSnapshot()
})
it('should throw an error a swipe command for direction foo', async () => {
await expect(
browser.swipe({
direction: 'foo' as MobileScrollDirection,
scrollableElement: elem
})
).rejects.toThrowErrorMatchingSnapshot()
})
it('should throw an error if the swipe command is called for a browser session', async () => {
browser = await remote({
baseUrl: 'http://foobar.com',
capabilities: {
browserName: 'foobar',
} as any
})
await expect(browser.swipe()).rejects.toThrowErrorMatchingSnapshot()
})
afterEach(() => {
vi.mocked(fetch).mockClear()
rectSpy.mockRestore()
logSpy.mockRestore()
})
})

View file

@ -63,7 +63,12 @@ await clock.<%= it.command %>(<%= it.paramString %>)
<% it.paramTags.forEach((paramTag) => { %>
<% if (paramTag.type === "rowInfo") { %>
<tr>
<td colspan="3"><strong><%= paramTag.string %></strong></td>
<% if (paramTag.string.includes('::')) { %>
<% const parts = paramTag.string.split('::').map(part => part.trim()); %>
<td colspan="3"><strong><%= parts[0] %></strong><br /><%= parts[1] %></td>
<% } else { %>
<td colspan="3"><strong><%= paramTag.string %></strong></td>
<% } %>
</tr>
<% } else { %>
<tr>

View file

@ -49,7 +49,6 @@ export default function (docfile: any) {
paramStr.push(tag.name)
} else if (tag.type === 'rowInfo') {
paramTags.push(tag)
paramStr.push(tag.string)
} else if (tag.type === 'property') {
tag.joinedTypes = Array.isArray(tag.types)
? tag.types.join('|').replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')