diff --git a/lib/helper/Playwright.js b/lib/helper/Playwright.js index 2ecbf0336..209406770 100644 --- a/lib/helper/Playwright.js +++ b/lib/helper/Playwright.js @@ -23,6 +23,8 @@ import { clearString, requireWithFallback, normalizeSpacesInString, + normalizePath, + resolveUrl, relativeDir, } from '../utils.js' import { isColorProperty, convertColorToRGBA } from '../colorUtils.js' @@ -2412,7 +2414,7 @@ class Playwright extends Helper { const currentUrl = await this._getPageUrl() const baseUrl = this.options.url || 'http://localhost' const actualPath = new URL(currentUrl, baseUrl).pathname - return equals('url path').assert(path, actualPath) + return equals('url path').assert(normalizePath(path), normalizePath(actualPath)) } /** @@ -2422,7 +2424,7 @@ class Playwright extends Helper { const currentUrl = await this._getPageUrl() const baseUrl = this.options.url || 'http://localhost' const actualPath = new URL(currentUrl, baseUrl).pathname - return equals('url path').negate(path, actualPath) + return equals('url path').negate(normalizePath(path), normalizePath(actualPath)) } /** @@ -3382,6 +3384,7 @@ class Playwright extends Helper { */ async waitInUrl(urlPart, sec = null) { const waitTimeout = sec ? sec * 1000 : this.options.waitForTimeout + const expectedUrl = resolveUrl(urlPart, this.options.url) return this.page .waitForFunction( @@ -3389,13 +3392,13 @@ class Playwright extends Helper { const currUrl = decodeURIComponent(decodeURIComponent(decodeURIComponent(window.location.href))) return currUrl.indexOf(urlPart) > -1 }, - urlPart, + expectedUrl, { timeout: waitTimeout }, ) .catch(async e => { - const currUrl = await this._getPageUrl() // Required because the waitForFunction can't return data. + const currUrl = await this._getPageUrl() if (/Timeout/i.test(e.message)) { - throw new Error(`expected url to include ${urlPart}, but found ${currUrl}`) + throw new Error(`expected url to include ${expectedUrl}, but found ${currUrl}`) } else { throw e } @@ -3407,26 +3410,46 @@ class Playwright extends Helper { */ async waitUrlEquals(urlPart, sec = null) { const waitTimeout = sec ? sec * 1000 : this.options.waitForTimeout - - const baseUrl = this.options.url - let expectedUrl = urlPart - if (urlPart.indexOf('http') < 0) { - expectedUrl = baseUrl + urlPart - } + const expectedUrl = resolveUrl(urlPart, this.options.url) try { await this.page.waitForURL( - url => url.href.includes(expectedUrl), + url => url.href === expectedUrl, { timeout: waitTimeout }, ) } catch (e) { const currUrl = await this._getPageUrl() if (/Timeout/i.test(e.message)) { - if (!currUrl.includes(expectedUrl)) { - throw new Error(`expected url to be ${expectedUrl}, but found ${currUrl}`) - } else { - throw new Error(`expected url not loaded, error message: ${e.message}`) - } + throw new Error(`expected url to be ${expectedUrl}, but found ${currUrl}`) + } else { + throw e + } + } + } + + /** + * {{> waitCurrentPathEquals }} + */ + async waitCurrentPathEquals(path, sec = null) { + const waitTimeout = sec ? sec * 1000 : this.options.waitForTimeout + const normalizedPath = normalizePath(path) + + try { + await this.page.waitForFunction( + expectedPath => { + const actualPath = window.location.pathname + const normalizePath = p => (p === '' || p === '/' ? '/' : p.replace(/\/+/g, '/').replace(/\/$/, '') || '/') + return normalizePath(actualPath) === expectedPath + }, + { timeout: waitTimeout }, + normalizedPath, + ) + } catch (e) { + const currentUrl = await this._getPageUrl() + const baseUrl = this.options.url || 'http://localhost' + const actualPath = new URL(currentUrl, baseUrl).pathname + if (/Timeout/i.test(e.message)) { + throw new Error(`expected path to be ${normalizedPath}, but found ${normalizePath(actualPath)}`) } else { throw e } diff --git a/lib/helper/Puppeteer.js b/lib/helper/Puppeteer.js index 0bf8e465c..81a7ad0a8 100644 --- a/lib/helper/Puppeteer.js +++ b/lib/helper/Puppeteer.js @@ -26,6 +26,8 @@ import { isModifierKey, requireWithFallback, normalizeSpacesInString, + normalizePath, + resolveUrl, } from '../utils.js' import { isColorProperty, convertColorToRGBA } from '../colorUtils.js' import ElementNotFound from './errors/ElementNotFound.js' @@ -1691,7 +1693,7 @@ class Puppeteer extends Helper { const currentUrl = await this._getPageUrl() const baseUrl = this.options.url || 'http://localhost' const actualPath = new URL(currentUrl, baseUrl).pathname - return equals('url path').assert(path, actualPath) + return equals('url path').assert(normalizePath(path), normalizePath(actualPath)) } /** @@ -1701,7 +1703,7 @@ class Puppeteer extends Helper { const currentUrl = await this._getPageUrl() const baseUrl = this.options.url || 'http://localhost' const actualPath = new URL(currentUrl, baseUrl).pathname - return equals('url path').negate(path, actualPath) + return equals('url path').negate(normalizePath(path), normalizePath(actualPath)) } /** @@ -2441,6 +2443,7 @@ class Puppeteer extends Helper { */ async waitInUrl(urlPart, sec = null) { const waitTimeout = sec ? sec * 1000 : this.options.waitForTimeout + const expectedUrl = resolveUrl(urlPart, this.options.url) return this.page .waitForFunction( @@ -2449,12 +2452,12 @@ class Puppeteer extends Helper { return currUrl.indexOf(urlPart) > -1 }, { timeout: waitTimeout }, - urlPart, + expectedUrl, ) .catch(async e => { - const currUrl = await this._getPageUrl() // Required because the waitForFunction can't return data. + const currUrl = await this._getPageUrl() if (/Waiting failed:/i.test(e.message) || /failed: timeout/i.test(e.message)) { - throw new Error(`expected url to include ${urlPart}, but found ${currUrl}`) + throw new Error(`expected url to include ${expectedUrl}, but found ${currUrl}`) } else { throw e } @@ -2466,18 +2469,13 @@ class Puppeteer extends Helper { */ async waitUrlEquals(urlPart, sec = null) { const waitTimeout = sec ? sec * 1000 : this.options.waitForTimeout - - const baseUrl = this.options.url - let expectedUrl = urlPart - if (urlPart.indexOf('http') < 0) { - expectedUrl = baseUrl + urlPart - } + const expectedUrl = resolveUrl(urlPart, this.options.url) return this.page .waitForFunction( url => { const currUrl = decodeURIComponent(window.location.href) - return currUrl.indexOf(url) > -1 + return currUrl === url }, { timeout: waitTimeout }, expectedUrl, @@ -2485,11 +2483,36 @@ class Puppeteer extends Helper { .catch(async e => { const currUrl = await this._getPageUrl() if (/Waiting failed/i.test(e.message) || /failed: timeout/i.test(e.message)) { - if (!currUrl.includes(expectedUrl)) { - throw new Error(`expected url to be ${expectedUrl}, but found ${currUrl}`) - } else { - throw new Error(`expected url not loaded, error message: ${e.message}`) - } + throw new Error(`expected url to be ${expectedUrl}, but found ${currUrl}`) + } else { + throw e + } + }) + } + + /** + * {{> waitCurrentPathEquals }} + */ + async waitCurrentPathEquals(path, sec = null) { + const waitTimeout = sec ? sec * 1000 : this.options.waitForTimeout + const normalizedPath = normalizePath(path) + + return this.page + .waitForFunction( + expectedPath => { + const actualPath = window.location.pathname + const normalizePath = p => (p === '' || p === '/' ? '/' : p.replace(/\/+/g, '/').replace(/\/$/, '') || '/') + return normalizePath(actualPath) === expectedPath + }, + { timeout: waitTimeout }, + normalizedPath, + ) + .catch(async e => { + const currUrl = await this._getPageUrl() + const baseUrl = this.options.url || 'http://localhost' + const actualPath = new URL(currUrl, baseUrl).pathname + if (/Waiting failed/i.test(e.message) || /failed: timeout/i.test(e.message)) { + throw new Error(`expected path to be ${normalizedPath}, but found ${normalizePath(actualPath)}`) } else { throw e } diff --git a/lib/helper/WebDriver.js b/lib/helper/WebDriver.js index e07234a53..e29ffee25 100644 --- a/lib/helper/WebDriver.js +++ b/lib/helper/WebDriver.js @@ -13,7 +13,18 @@ import output from '../output.js' const { debug } = output import { empty } from '../assert/empty.js' import { truth } from '../assert/truth.js' -import { xpathLocator, fileExists, decodeUrl, chunkArray, convertCssPropertiesToCamelCase, screenshotOutputFolder, getNormalizedKeyAttributeValue, modifierKeys } from '../utils.js' +import { + xpathLocator, + fileExists, + decodeUrl, + chunkArray, + convertCssPropertiesToCamelCase, + screenshotOutputFolder, + getNormalizedKeyAttributeValue, + modifierKeys, + normalizePath, + resolveUrl, +} from '../utils.js' import { isColorProperty, convertColorToRGBA } from '../colorUtils.js' import ElementNotFound from './errors/ElementNotFound.js' import ConnectionRefused from './errors/ConnectionRefused.js' @@ -1851,7 +1862,7 @@ class WebDriver extends Helper { const currentUrl = await this.browser.getUrl() const baseUrl = this.options.url || 'http://localhost' const actualPath = new URL(currentUrl, baseUrl).pathname - return equals('url path').assert(path, actualPath) + return equals('url path').assert(normalizePath(path), normalizePath(actualPath)) } /** @@ -1861,7 +1872,7 @@ class WebDriver extends Helper { const currentUrl = await this.browser.getUrl() const baseUrl = this.options.url || 'http://localhost' const actualPath = new URL(currentUrl, baseUrl).pathname - return equals('url path').negate(path, actualPath) + return equals('url path').negate(normalizePath(path), normalizePath(actualPath)) } /** @@ -2487,6 +2498,7 @@ class WebDriver extends Helper { async waitInUrl(urlPart, sec = null) { const client = this.browser const aSec = sec || this.options.waitForTimeoutInSeconds + const expectedUrl = resolveUrl(urlPart, this.options.url) let currUrl = '' return client @@ -2494,7 +2506,7 @@ class WebDriver extends Helper { function () { return this.getUrl().then(res => { currUrl = decodeUrl(res) - return currUrl.indexOf(urlPart) > -1 + return currUrl.indexOf(expectedUrl) > -1 }) }, { timeout: aSec * 1000 }, @@ -2502,7 +2514,7 @@ class WebDriver extends Helper { .catch(e => { e = wrapError(e) if (e.message.indexOf('timeout')) { - throw new Error(`expected url to include ${urlPart}, but found ${currUrl}`) + throw new Error(`expected url to include ${expectedUrl}, but found ${currUrl}`) } throw e }) @@ -2513,22 +2525,47 @@ class WebDriver extends Helper { */ async waitUrlEquals(urlPart, sec = null) { const aSec = sec || this.options.waitForTimeoutInSeconds - const baseUrl = this.options.url - if (urlPart.indexOf('http') < 0) { - urlPart = baseUrl + urlPart - } + const expectedUrl = resolveUrl(urlPart, this.options.url) let currUrl = '' return this.browser .waitUntil(function () { return this.getUrl().then(res => { currUrl = decodeUrl(res) - return currUrl === urlPart + return currUrl === expectedUrl }) }, aSec * 1000) .catch(e => { e = wrapError(e) if (e.message.indexOf('timeout')) { - throw new Error(`expected url to be ${urlPart}, but found ${currUrl}`) + throw new Error(`expected url to be ${expectedUrl}, but found ${currUrl}`) + } + throw e + }) + } + + /** + * {{> waitCurrentPathEquals }} + */ + async waitCurrentPathEquals(path, sec = null) { + const aSec = sec || this.options.waitForTimeoutInSeconds + const normalizedPath = normalizePath(path) + const baseUrl = this.options.url || 'http://localhost' + let actualPath = '' + + return this.browser + .waitUntil( + async () => { + const currUrl = await this.browser.getUrl() + const url = new URL(currUrl, baseUrl) + actualPath = url.pathname + return normalizePath(actualPath) === normalizedPath + }, + { timeout: aSec * 1000 }, + ) + .catch(e => { + e = wrapError(e) + if (e.message.indexOf('timeout')) { + throw new Error(`expected path to be ${normalizedPath}, but found ${normalizePath(actualPath)}`) } throw e }) diff --git a/lib/utils.js b/lib/utils.js index 1458f387c..f3b2a2319 100644 --- a/lib/utils.js +++ b/lib/utils.js @@ -150,6 +150,24 @@ export const decodeUrl = function (url) { return decodeURIComponent(decodeURIComponent(decodeURIComponent(url))) } +export const normalizePath = function (path) { + if (path === '' || path === '/') return '/' + return path + .replace(/\/+/g, '/') + .replace(/\/$/, '') || '/' +} + +export const resolveUrl = function (url, baseUrl) { + if (!url) return url + if (url.indexOf('http') === 0) return url + if (!baseUrl) return url + try { + return new URL(url, baseUrl).href + } catch (e) { + return url + } +} + export const xpathLocator = { /** * @param {string} string diff --git a/test/helper/webapi.js b/test/helper/webapi.js index 842a9d81b..420d8fc37 100644 --- a/test/helper/webapi.js +++ b/test/helper/webapi.js @@ -114,6 +114,39 @@ export function tests() { await I.seeCurrentPathEquals('/info') await I.dontSeeCurrentPathEquals('/info#section') }) + + it('should normalize trailing slashes in path comparison', async () => { + await I.amOnPage('/info/') + await I.seeCurrentPathEquals('/info') + await I.seeCurrentPathEquals('/info/') + + await I.amOnPage('/form/field/') + await I.seeCurrentPathEquals('/form/field') + await I.seeCurrentPathEquals('/form/field/') + }) + + it('should normalize multiple consecutive slashes in path', async () => { + await I.amOnPage('/form//field') + await I.seeCurrentPathEquals('/form/field') + await I.seeCurrentPathEquals('/form//field') + }) + + it('should handle root path correctly', async () => { + await I.amOnPage('/') + await I.seeCurrentPathEquals('/') + await I.seeCurrentPathEquals('') + await I.dontSeeCurrentPathEquals('/info') + }) + + it('should normalize both expected and actual paths', async () => { + await I.amOnPage('/form/field/') + await I.seeCurrentPathEquals('/form/field/') + await I.seeCurrentPathEquals('/form/field') + + await I.amOnPage('/form//field//') + await I.seeCurrentPathEquals('/form/field') + await I.seeCurrentPathEquals('/form/field/') + }) }) describe('#waitInUrl, #waitUrlEquals', () => { @@ -123,7 +156,7 @@ export function tests() { await I.waitInUrl('/info') await I.waitInUrl('/info2', 0.1) } catch (e) { - assert.include(e.message, `expected url to include /info2, but found ${siteUrl}/info`) + assert.include(e.message, `expected url to include ${siteUrl}/info2, but found ${siteUrl}/info`) } }) @@ -139,6 +172,28 @@ export function tests() { }) }) + describe('#waitCurrentPathEquals', () => { + it('should wait for path to match (ignoring query strings)', async () => { + await I.amOnPage('/info') + await I.waitCurrentPathEquals('/info') + }) + + it('should wait timeout with proper error message', async () => { + try { + await I.amOnPage('/info') + await I.waitCurrentPathEquals('/nonexistent', 0.1) + } catch (e) { + assert.include(e.message, 'expected path to be /nonexistent') + } + }) + + it('should normalize paths when comparing', async () => { + await I.amOnPage('/form/field/') + await I.waitCurrentPathEquals('/form/field') + await I.waitCurrentPathEquals('/form/field/') + }) + }) + describe('see text : #see', () => { it('should check text on site', async () => { await I.amOnPage('/')