mirror of
https://github.com/cypress-io/cypress.git
synced 2026-03-03 05:19:45 -06:00
Rewrite JS/HTML using AST-based approach (#5273)
* Add winPropAccessor to security.js, remove other replacers
* Add start of Cypress.resolveWindowReference
* Add regexes for dot and bracket access
* Some security_spec tests pass with new injection
* Add resolveWindowReference unit tests
* Old security_spec now passes with resolveWindowReference
* Inject stub resolveWindowReference so proxy still works outside of Cypress
* wip: rewrite HTML + JS with tokenizer
* Move to using esprima + hyntax to rewrite JS + HTML
* remove comment; oneLine makes the whole thing commented
* Fix tests, apple.com edge case
* wip: add getOrSet
* Revert "wip: add getOrSet"
This reverts commit a5c647c00f.
* release 3.5.0 [skip ci]
* use recast to replace window property accesses
* replace assignments to top properly
* fix yarn.lock
* bump deps
* update integration tests
* remove old security ts?
* fix integration spec
* always ignore js interception failure
* use globalThis instead of window
* add experimentalSourceRewriting flag
* restore regex-writer spec
* fix types
* update config_spec
* add source rewriting spec
* cleanup
* simplify rewriting logic, move rules into rewriter package
* create threaded rewriting tool for non-streaming use
* update @packages/rewriter to use threads for async
* use async rewriting where convenient
* add worker-shim.js
* add performance info to debug logs
* properly handle +=, -=, ...
* add proxy, rewriter to unit-tests stage
* cleanup
* use parse5 to rewrite HTML, strip SRI
* update tests
* reorganization, cleanup
* rewrite ALL parent, top identifiers except in a few cases
* handle many JS edge cases
* ensure parse5@5.1.1 is installed
* update yarn.lock
* update tests
* add debugging, add tests
* add attempted repro for .href issue
* implement source maps + extending inline source maps
* update opts passing in proxy layer
* fix sourcemap naming structure
* update tests to account for sourcemaps
* sourcemap tests
* remote source maps work
* comment
* update rewriter tests
* clean up TODOs in resolveWindowReference
* remove @types/nock
* clean up todos in deferred-source-map-cache
* fix rewriter build script
* fix concatStream import
* bump expectedresultcount
* clean up js-rules
* threading improvements, workaround for Electron segfault
* no visit_spec for now
* fix 6_visit_spec
* update MAX_WORKER_THREADS
* add repro for #3975
* cleanup
* cleanup
* make better use of namedTypes and builders
* get rid of the horrific closureDetectionTernary
ast-types keeps track of scope, so it is unneeded
* fix #3975, #3994
* add x-sourcemap, sourcemap header support
* snap-shot-it 7.9.3
* add deferred-source-map-cache-spec
* add tests
* Throw error in driver if AST rewriting fails
* Fix "location = 'relative-url'"
* fix max recursion depth
* slim down some fixtures
* fix window.location usage
* don't mess with `frames` at all
* no integration tests
* skip testing apple.com for now
* update wording: regex-based vs. ast-based
* skip real-world tests for now
* add some padding to process.exit workaround
* fix resolvers_spec
* fix html-spec
* cleanup
* Update packages/rewriter/lib/js-rules.ts
* Update packages/driver/src/cypress/resolvers.ts
* just import find by itself
* privatize typedefs for Cypress.state, remove .gitignore, remove dead code
Co-authored-by: Ben Kucera <14625260+Bkucera@users.noreply.github.com>
This commit is contained in:
@@ -330,7 +330,7 @@ jobs:
|
||||
# run unit tests from each individual package
|
||||
- run: yarn test
|
||||
- verify-mocha-results:
|
||||
expectedResultCount: 6
|
||||
expectedResultCount: 8
|
||||
- store_test_results:
|
||||
path: /tmp/cypress
|
||||
# CLI tests generate HTML files with sample CLI command output
|
||||
|
||||
@@ -228,6 +228,11 @@
|
||||
"type": "boolean",
|
||||
"default": false,
|
||||
"description": "If `true`, Cypress will add `sameSite` values to the objects yielded from `cy.setCookie()`, `cy.getCookie()`, and `cy.getCookies()`. This will become the default behavior in Cypress 5.0."
|
||||
},
|
||||
"experimentalSourceRewriting": {
|
||||
"type": "boolean",
|
||||
"default": false,
|
||||
"description": "Enables AST-based JS/HTML rewriting. This may fix issues caused by the existing regex-based JS/HTML replacement algorithm."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
10
cli/types/index.d.ts
vendored
10
cli/types/index.d.ts
vendored
@@ -478,11 +478,11 @@ declare namespace Cypress {
|
||||
/**
|
||||
* Returns a boolean indicating whether an object is a window object.
|
||||
*/
|
||||
isWindow(obj: any): boolean
|
||||
isWindow(obj: any): obj is Window
|
||||
/**
|
||||
* Returns a boolean indicating whether an object is a jQuery object.
|
||||
*/
|
||||
isJquery(obj: any): boolean
|
||||
isJquery(obj: any): obj is JQuery
|
||||
isInputType(element: JQuery | HTMLElement, type: string | string[]): boolean
|
||||
stringify(element: JQuery | HTMLElement, form: string): string
|
||||
getElements(element: JQuery): JQuery | HTMLElement[]
|
||||
@@ -2456,6 +2456,12 @@ declare namespace Cypress {
|
||||
* @default false
|
||||
*/
|
||||
experimentalGetCookiesSameSite: boolean
|
||||
/**
|
||||
* Enables AST-based JS/HTML rewriting. This may fix issues caused by the existing regex-based JS/HTML replacement
|
||||
* algorithm.
|
||||
* @default false
|
||||
*/
|
||||
experimentalSourceRewriting: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -51,7 +51,7 @@
|
||||
"stop-only": "npx stop-only --skip .cy,.publish,.projects,node_modules,dist,dist-test,fixtures,lib,bower_components,src --exclude e2e.coffee,e2e.js",
|
||||
"stop-only-all": "yarn stop-only --folder packages",
|
||||
"pretest": "yarn ensure-deps",
|
||||
"test": "yarn lerna exec yarn test --scope cypress --scope \"'@packages/{electron,extension,https-proxy,launcher,network,reporter,runner,socket}'\"",
|
||||
"test": "yarn lerna exec yarn test --scope cypress --scope \"'@packages/{electron,extension,https-proxy,launcher,network,proxy,rewriter,reporter,runner,socket}'\"",
|
||||
"test-debug": "lerna exec yarn test-debug --ignore \"'@packages/{coffee,desktop-gui,driver,root,static,web-config}'\"",
|
||||
"pretest-e2e": "yarn ensure-deps",
|
||||
"test-e2e": "lerna exec yarn test-e2e --ignore \"'@packages/{coffee,desktop-gui,driver,root,static,web-config}'\"",
|
||||
|
||||
@@ -32,6 +32,7 @@ const $utils = require('./cypress/utils')
|
||||
const $errUtils = require('./cypress/error_utils')
|
||||
const $scriptUtils = require('./cypress/script_utils')
|
||||
const browserInfo = require('./cypress/browser')
|
||||
const resolvers = require('./cypress/resolvers')
|
||||
const debug = require('debug')('cypress:driver:cypress')
|
||||
|
||||
const proxies = {
|
||||
@@ -607,6 +608,8 @@ $Cypress.prototype.Location = $Location
|
||||
$Cypress.prototype.Log = $Log
|
||||
$Cypress.prototype.LocalStorage = $LocalStorage
|
||||
$Cypress.prototype.Mocha = $Mocha
|
||||
$Cypress.prototype.resolveWindowReference = resolvers.resolveWindowReference
|
||||
$Cypress.prototype.resolveLocationReference = resolvers.resolveLocationReference
|
||||
$Cypress.prototype.Mouse = $Mouse
|
||||
$Cypress.prototype.Runner = $Runner
|
||||
$Cypress.prototype.Server = $Server
|
||||
|
||||
@@ -841,6 +841,18 @@ module.exports = {
|
||||
ng:
|
||||
no_global: "Angular global (`window.angular`) was not found in your window. You cannot use #{cmd('ng')} methods without angular."
|
||||
|
||||
proxy:
|
||||
js_rewriting_failed: """
|
||||
An error occurred in the Cypress proxy layer while rewriting your source code. This is a bug in Cypress.
|
||||
|
||||
JS URL: {{url}}
|
||||
|
||||
Original error:
|
||||
|
||||
{{errMessage}}
|
||||
{{errStack}}
|
||||
"""
|
||||
|
||||
reload:
|
||||
invalid_arguments: {
|
||||
message: "#{cmd('reload')} can only accept a boolean or `options` as its arguments."
|
||||
|
||||
159
packages/driver/src/cypress/resolvers.ts
Normal file
159
packages/driver/src/cypress/resolvers.ts
Normal file
@@ -0,0 +1,159 @@
|
||||
import _ from 'lodash'
|
||||
import $Cypress from '../..'
|
||||
|
||||
/**
|
||||
* Fix property reads and writes that could potentially help the AUT to break out of its iframe.
|
||||
*
|
||||
* @param currentWindow the value of `globalThis` from the scope of the window reference in question
|
||||
* @param accessedObject a reference to the object being accessed
|
||||
* @param accessedProp the property name being accessed (Symbol/number properties are not intercepted)
|
||||
* @param value the right-hand side of an assignment operation (accessedObject.accessedProp = value)
|
||||
*/
|
||||
export function resolveWindowReference (this: typeof $Cypress, currentWindow: Window, accessedObject: Window | any, accessedProp: string, value?: any) {
|
||||
const { dom, state } = this
|
||||
|
||||
const getTargetValue = () => {
|
||||
const targetValue = accessedObject[accessedProp]
|
||||
|
||||
if (dom.isWindow(accessedObject) && accessedProp === 'location') {
|
||||
const targetLocation = resolveLocationReference(accessedObject)
|
||||
|
||||
if (isValPassed) {
|
||||
return targetLocation.href = value
|
||||
}
|
||||
|
||||
return targetLocation
|
||||
}
|
||||
|
||||
if (_.isFunction(targetValue)) {
|
||||
return targetValue.bind(accessedObject)
|
||||
}
|
||||
|
||||
return targetValue
|
||||
}
|
||||
|
||||
const setTargetValue = () => {
|
||||
if (dom.isWindow(accessedObject) && accessedProp === 'location') {
|
||||
const targetLocation = resolveLocationReference(accessedObject)
|
||||
|
||||
return targetLocation.href = value
|
||||
}
|
||||
|
||||
return (accessedObject[accessedProp] = value)
|
||||
}
|
||||
|
||||
const isValPassed = arguments.length === 4
|
||||
|
||||
const $autIframe = state('$autIframe')
|
||||
|
||||
if (!$autIframe) {
|
||||
// missing AUT iframe, resolve the property access normally
|
||||
if (isValPassed) {
|
||||
return setTargetValue()
|
||||
}
|
||||
|
||||
return getTargetValue()
|
||||
}
|
||||
|
||||
const contentWindow = $autIframe.prop('contentWindow')
|
||||
|
||||
if (accessedObject === currentWindow.top) {
|
||||
// doing a property access on topmost window, adjust accessedObject
|
||||
accessedObject = contentWindow
|
||||
}
|
||||
|
||||
const targetValue = getTargetValue()
|
||||
|
||||
if (!dom.isWindow(targetValue) || dom.isJquery(targetValue)) {
|
||||
if (isValPassed) {
|
||||
return setTargetValue()
|
||||
}
|
||||
|
||||
return targetValue
|
||||
}
|
||||
|
||||
// targetValue is a reference to a Window object
|
||||
|
||||
if (accessedProp === 'top') {
|
||||
// note: `isValPassed` is not considered here because `window.top` is readonly
|
||||
return contentWindow
|
||||
}
|
||||
|
||||
if (accessedProp === 'parent') {
|
||||
// note: `isValPassed` is not considered here because `window.parent` is readonly
|
||||
if (targetValue === currentWindow.top) {
|
||||
return contentWindow
|
||||
}
|
||||
|
||||
return targetValue
|
||||
}
|
||||
|
||||
throw new Error('unhandled resolveWindowReference')
|
||||
}
|
||||
|
||||
/**
|
||||
* Fix `window.location` usages that would otherwise navigate to the wrong URL.
|
||||
*
|
||||
* @param currentWindow the value of `globalThis` from the scope of the location reference in question
|
||||
*/
|
||||
export function resolveLocationReference (currentWindow: Window) {
|
||||
// @ts-ignore
|
||||
if (currentWindow.__cypressFakeLocation) {
|
||||
// @ts-ignore
|
||||
return currentWindow.__cypressFakeLocation
|
||||
}
|
||||
|
||||
function _resolveHref (href: string) {
|
||||
const a = currentWindow.document.createElement('a')
|
||||
|
||||
a.href = href
|
||||
|
||||
// a.href will be resolved into the correct fully-qualified URL
|
||||
return a.href
|
||||
}
|
||||
|
||||
function assign (href: string) {
|
||||
return currentWindow.location.assign(_resolveHref(href))
|
||||
}
|
||||
|
||||
function replace (href: string) {
|
||||
return currentWindow.location.replace(_resolveHref(href))
|
||||
}
|
||||
|
||||
function setHref (href: string) {
|
||||
return currentWindow.location.href = _resolveHref(href)
|
||||
}
|
||||
|
||||
const locationKeys = Object.keys(currentWindow.location)
|
||||
|
||||
const fakeLocation = {}
|
||||
|
||||
_.reduce(locationKeys, (acc, cur) => {
|
||||
// set a dummy value, the proxy will handle sets/gets
|
||||
acc[cur] = Symbol.for('Proxied')
|
||||
|
||||
return acc
|
||||
}, {})
|
||||
|
||||
// @ts-ignore
|
||||
return currentWindow.__cypressFakeLocation = new Proxy(fakeLocation, {
|
||||
get (_target, prop, _receiver) {
|
||||
if (prop === 'assign') {
|
||||
return assign
|
||||
}
|
||||
|
||||
if (prop === 'replace') {
|
||||
return replace
|
||||
}
|
||||
|
||||
return currentWindow.location[prop]
|
||||
},
|
||||
set (_obj, prop, value) {
|
||||
if (prop === 'href') {
|
||||
return setHref(value)
|
||||
}
|
||||
|
||||
return currentWindow.location[prop] = value
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,212 @@
|
||||
const getFakeWindowWithLocation = ($win: Window) => {
|
||||
return {
|
||||
document: $win.document,
|
||||
location: {
|
||||
someFn: cy.stub(),
|
||||
someProp: 'foo',
|
||||
href: 'original',
|
||||
assign: cy.stub(),
|
||||
replace: cy.stub(),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
describe('src/cypress/resolvers', function () {
|
||||
context('#resolveWindowReferences', function () {
|
||||
it('returns bound fn if prop is fn', function () {
|
||||
const unboundFn = function () {
|
||||
return this
|
||||
}
|
||||
const unboundFnWindow = {
|
||||
parent: unboundFn,
|
||||
}
|
||||
|
||||
// @ts-ignore
|
||||
cy.spy(unboundFn, 'bind')
|
||||
|
||||
// @ts-ignore
|
||||
const actual = Cypress.resolveWindowReference({}, unboundFnWindow, 'parent')
|
||||
|
||||
expect(actual).to.be.instanceOf(Function)
|
||||
expect(actual()).to.eq(unboundFnWindow)
|
||||
expect(unboundFn.bind).to.be.calledWith(unboundFnWindow)
|
||||
})
|
||||
|
||||
it('returns proxied location object if prop is location', function () {
|
||||
const contentWindow = Cypress.state('$autIframe')!.prop('contentWindow')
|
||||
// @ts-ignore
|
||||
const actual = Cypress.resolveWindowReference(contentWindow, contentWindow, 'location')
|
||||
|
||||
cy.stub(Cypress.dom, 'isWindow').withArgs(contentWindow).returns(true)
|
||||
cy.stub(Cypress.dom, 'isJquery').withArgs(contentWindow).returns(false)
|
||||
|
||||
// @ts-ignore
|
||||
expect(actual).to.eq(Cypress.resolveLocationReference(contentWindow))
|
||||
})
|
||||
|
||||
context('window reference selection', function () {
|
||||
const cypressFrame = {
|
||||
name: 'cypressFrame',
|
||||
parent: null as unknown,
|
||||
top: null as unknown,
|
||||
}
|
||||
|
||||
cypressFrame.parent = cypressFrame.top = cypressFrame
|
||||
|
||||
const autIframe = {
|
||||
name: 'autIframe',
|
||||
parent: cypressFrame,
|
||||
top: cypressFrame,
|
||||
}
|
||||
|
||||
const nestedIframe = {
|
||||
name: 'nestedIframe',
|
||||
parent: autIframe,
|
||||
top: cypressFrame,
|
||||
}
|
||||
|
||||
const doublyNestedIframe = {
|
||||
name: 'doublyNestedIframe',
|
||||
parent: nestedIframe,
|
||||
top: cypressFrame,
|
||||
}
|
||||
|
||||
;[
|
||||
{
|
||||
name: 'returns autIframe given parent call in autIframe',
|
||||
currentWindow: autIframe,
|
||||
accessedObject: autIframe,
|
||||
accessedProp: 'parent',
|
||||
expected: autIframe,
|
||||
},
|
||||
{
|
||||
name: 'returns autIframe given top call in autIframe',
|
||||
currentWindow: autIframe,
|
||||
accessedObject: autIframe,
|
||||
accessedProp: 'top',
|
||||
expected: autIframe,
|
||||
},
|
||||
{
|
||||
name: 'returns autIframe given parent call in nestedIframe',
|
||||
currentWindow: nestedIframe,
|
||||
accessedObject: nestedIframe,
|
||||
accessedProp: 'parent',
|
||||
expected: autIframe,
|
||||
},
|
||||
{
|
||||
name: 'returns autIframe given top call in nestedIframe',
|
||||
currentWindow: nestedIframe,
|
||||
accessedObject: nestedIframe,
|
||||
accessedProp: 'top',
|
||||
expected: autIframe,
|
||||
},
|
||||
{
|
||||
name: 'returns nestedIframe given parent call in doublyNestedIframe',
|
||||
currentWindow: doublyNestedIframe,
|
||||
accessedObject: doublyNestedIframe,
|
||||
accessedProp: 'parent',
|
||||
expected: nestedIframe,
|
||||
},
|
||||
{
|
||||
name: 'returns autIframe given top call in doublyNestedIframe',
|
||||
currentWindow: doublyNestedIframe,
|
||||
accessedObject: doublyNestedIframe,
|
||||
accessedProp: 'top',
|
||||
expected: autIframe,
|
||||
},
|
||||
]
|
||||
// .slice(0, 1)
|
||||
.forEach(({ name, currentWindow, accessedObject, accessedProp, expected }) => {
|
||||
it(name, function () {
|
||||
const isWindow = cy.stub(Cypress.dom, 'isWindow')
|
||||
const isJquery = cy.stub(Cypress.dom, 'isJquery')
|
||||
const state = cy.stub(Cypress, 'state')
|
||||
|
||||
state.withArgs('$autIframe').returns({
|
||||
prop: cy.stub().withArgs('contentWindow').returns(autIframe),
|
||||
})
|
||||
|
||||
;[cypressFrame, autIframe, nestedIframe, doublyNestedIframe].forEach((frame) => {
|
||||
isWindow.withArgs(frame).returns(true)
|
||||
isJquery.withArgs(frame).returns(false)
|
||||
})
|
||||
|
||||
// @ts-ignore
|
||||
const actual = Cypress.resolveWindowReference(currentWindow, accessedObject, accessedProp)
|
||||
|
||||
expect(actual).to.eq(expected)
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
context('#resolveLocationReference', function () {
|
||||
let fakeWindow
|
||||
|
||||
beforeEach(() => {
|
||||
cy.visit('/fixtures/generic.html')
|
||||
.then(($win) => {
|
||||
fakeWindow = getFakeWindowWithLocation($win)
|
||||
})
|
||||
})
|
||||
|
||||
it('.href setter sets location.href with resolved URL', () => {
|
||||
// @ts-ignore
|
||||
const loc = Cypress.resolveLocationReference(fakeWindow)
|
||||
|
||||
loc.href = 'foo'
|
||||
|
||||
expect(fakeWindow.location.href).to.eq('http://localhost:3500/fixtures/foo')
|
||||
})
|
||||
|
||||
it('.assign() calls location.assign with resolved URL', () => {
|
||||
// @ts-ignore
|
||||
const loc = Cypress.resolveLocationReference(fakeWindow)
|
||||
|
||||
loc.assign('foo')
|
||||
|
||||
expect(fakeWindow.location.assign).to.be.calledWith('http://localhost:3500/fixtures/foo')
|
||||
})
|
||||
|
||||
it('.replace() calls location.replace with resolved URL', () => {
|
||||
// @ts-ignore
|
||||
const loc = Cypress.resolveLocationReference(fakeWindow)
|
||||
|
||||
loc.replace('foo')
|
||||
|
||||
expect(fakeWindow.location.replace).to.be.calledWith('http://localhost:3500/fixtures/foo')
|
||||
})
|
||||
|
||||
it('calls through to unintercepted functions', () => {
|
||||
// @ts-ignore
|
||||
const loc = Cypress.resolveLocationReference(fakeWindow)
|
||||
|
||||
loc.someFn('foo')
|
||||
|
||||
expect(fakeWindow.location.someFn).to.be.calledWith('foo')
|
||||
})
|
||||
|
||||
it('calls through to unintercepted setters + getters', () => {
|
||||
// @ts-ignore
|
||||
const loc = Cypress.resolveLocationReference(fakeWindow)
|
||||
|
||||
expect(loc.someProp).to.eq('foo')
|
||||
|
||||
loc.someProp = 'bar'
|
||||
|
||||
expect(loc.someProp).to.eq('bar')
|
||||
expect(fakeWindow.location.someProp).to.eq('bar')
|
||||
})
|
||||
|
||||
it('returns the same object between calls', () => {
|
||||
// @ts-ignore
|
||||
const loc1 = Cypress.resolveLocationReference(fakeWindow)
|
||||
// @ts-ignore
|
||||
const loc2 = Cypress.resolveLocationReference(fakeWindow)
|
||||
|
||||
expect(loc1).to.eq(loc2)
|
||||
expect(fakeWindow.__cypressFakeLocation).to.eq(loc1)
|
||||
expect(fakeWindow.__cypressFakeLocation).to.eq(loc2)
|
||||
})
|
||||
})
|
||||
})
|
||||
17
packages/driver/ts/internal-types.d.ts
vendored
Normal file
17
packages/driver/ts/internal-types.d.ts
vendored
Normal file
@@ -0,0 +1,17 @@
|
||||
// NOTE: this is for internal Cypress types that we don't want exposed in the public API but want for development
|
||||
// TODO: find a better place for this
|
||||
|
||||
declare namespace Cypress {
|
||||
|
||||
interface Cypress {
|
||||
/**
|
||||
* Access and set Cypress's internal state.
|
||||
*/
|
||||
state: State
|
||||
|
||||
}
|
||||
|
||||
interface State {
|
||||
(k: '$autIframe', v?: JQuery<HTMLIFrameElement>): JQuery<HTMLIFrameElement> | undefined
|
||||
}
|
||||
}
|
||||
@@ -43,7 +43,7 @@ export function getAddress (port: number, hostname: string) {
|
||||
return Array.prototype.concat.call(addresses).map(fn)
|
||||
})
|
||||
.tapCatch((err) => {
|
||||
debug('error getting address', { hostname, port, err })
|
||||
debug('error getting address %o', { hostname, port, err })
|
||||
})
|
||||
.any()
|
||||
}
|
||||
|
||||
@@ -3,11 +3,12 @@ import debugModule from 'debug'
|
||||
import ErrorMiddleware from './error-middleware'
|
||||
import { HttpBuffers } from './util/buffers'
|
||||
import { IncomingMessage } from 'http'
|
||||
import Promise from 'bluebird'
|
||||
import Bluebird from 'bluebird'
|
||||
import { Readable } from 'stream'
|
||||
import { Request, Response } from 'express'
|
||||
import RequestMiddleware from './request-middleware'
|
||||
import ResponseMiddleware from './response-middleware'
|
||||
import { DeferredSourceMapCache } from '@packages/rewriter'
|
||||
|
||||
const debug = debugModule('cypress:proxy:http')
|
||||
|
||||
@@ -42,6 +43,7 @@ type HttpMiddlewareCtx<T> = {
|
||||
res: CypressResponse
|
||||
|
||||
middleware: MiddlewareStacks
|
||||
deferSourceMapRewrite: (opts: { js: string, url: string }) => string
|
||||
} & T
|
||||
|
||||
const READONLY_MIDDLEWARE_KEYS: (keyof HttpMiddlewareThis<{}>)[] = [
|
||||
@@ -86,14 +88,14 @@ export function _runStage (type: HttpStages, ctx: any) {
|
||||
const middlewareName = _.keys(middlewares)[0]
|
||||
|
||||
if (!middlewareName) {
|
||||
return Promise.resolve()
|
||||
return Bluebird.resolve()
|
||||
}
|
||||
|
||||
const middleware = middlewares[middlewareName]
|
||||
|
||||
ctx.middleware[type] = _.omit(middlewares, middlewareName)
|
||||
|
||||
return new Promise((resolve) => {
|
||||
return new Bluebird((resolve) => {
|
||||
let ended = false
|
||||
|
||||
function copyChangedCtx () {
|
||||
@@ -173,6 +175,7 @@ export function _runStage (type: HttpStages, ctx: any) {
|
||||
|
||||
export class Http {
|
||||
buffers: HttpBuffers
|
||||
deferredSourceMapCache: DeferredSourceMapCache
|
||||
config: any
|
||||
getFileServerToken: () => string
|
||||
getRemoteState: () => any
|
||||
@@ -187,6 +190,7 @@ export class Http {
|
||||
request: any
|
||||
}) {
|
||||
this.buffers = new HttpBuffers()
|
||||
this.deferredSourceMapCache = new DeferredSourceMapCache(opts.request)
|
||||
|
||||
this.config = opts.config
|
||||
this.getFileServerToken = opts.getFileServerToken
|
||||
@@ -204,7 +208,7 @@ export class Http {
|
||||
}
|
||||
}
|
||||
|
||||
handle (req, res) {
|
||||
handle (req: Request, res: Response) {
|
||||
const ctx: HttpMiddlewareCtx<any> = {
|
||||
req,
|
||||
res,
|
||||
@@ -215,6 +219,12 @@ export class Http {
|
||||
getRemoteState: this.getRemoteState,
|
||||
request: this.request,
|
||||
middleware: _.cloneDeep(this.middleware),
|
||||
deferSourceMapRewrite: (opts) => {
|
||||
this.deferredSourceMapCache.defer({
|
||||
resHeaders: ctx.incomingRes.headers,
|
||||
...opts,
|
||||
})
|
||||
},
|
||||
}
|
||||
|
||||
return _runStage(HttpStages.IncomingRequest, ctx)
|
||||
@@ -227,6 +237,20 @@ export class Http {
|
||||
})
|
||||
}
|
||||
|
||||
async handleSourceMapRequest (req: Request, res: Response) {
|
||||
try {
|
||||
const sm = await this.deferredSourceMapCache.resolve(req.params.id, req.headers)
|
||||
|
||||
if (!sm) {
|
||||
throw new Error('no sourcemap found')
|
||||
}
|
||||
|
||||
res.json(sm)
|
||||
} catch (err) {
|
||||
res.status(500).json({ err })
|
||||
}
|
||||
}
|
||||
|
||||
reset () {
|
||||
this.buffers.reset()
|
||||
}
|
||||
|
||||
@@ -74,6 +74,10 @@ function resContentTypeIsJavaScript (res: IncomingMessage) {
|
||||
)
|
||||
}
|
||||
|
||||
function isHtml (res: IncomingMessage) {
|
||||
return !resContentTypeIsJavaScript(res)
|
||||
}
|
||||
|
||||
function resIsGzipped (res: IncomingMessage) {
|
||||
return (res.headers['content-encoding'] || '').includes('gzip')
|
||||
}
|
||||
@@ -364,10 +368,18 @@ const MaybeInjectHtml: ResponseMiddleware = function () {
|
||||
|
||||
debug('injecting into HTML')
|
||||
|
||||
this.incomingResStream.pipe(concatStream((body) => {
|
||||
this.incomingResStream.pipe(concatStream(async (body) => {
|
||||
const nodeCharset = getNodeCharsetFromResponse(this.incomingRes.headers, body)
|
||||
const decodedBody = iconv.decode(body, nodeCharset)
|
||||
const injectedBody = rewriter.html(decodedBody, this.getRemoteState().domainName, this.res.wantsInjection, this.res.wantsSecurityRemoved)
|
||||
const injectedBody = await rewriter.html(decodedBody, {
|
||||
domainName: this.getRemoteState().domainName,
|
||||
wantsInjection: this.res.wantsInjection,
|
||||
wantsSecurityRemoved: this.res.wantsSecurityRemoved,
|
||||
isHtml: isHtml(this.incomingRes),
|
||||
useAstSourceRewriting: this.config.experimentalSourceRewriting,
|
||||
url: this.req.proxiedUrl,
|
||||
deferSourceMapRewrite: this.deferSourceMapRewrite,
|
||||
})
|
||||
const encodedBody = iconv.encode(injectedBody, nodeCharset)
|
||||
|
||||
const pt = new PassThrough
|
||||
@@ -388,7 +400,13 @@ const MaybeRemoveSecurity: ResponseMiddleware = function () {
|
||||
debug('removing JS framebusting code')
|
||||
|
||||
this.incomingResStream.setEncoding('utf8')
|
||||
this.incomingResStream = this.incomingResStream.pipe(rewriter.security()).on('error', this.onError)
|
||||
this.incomingResStream = this.incomingResStream.pipe(rewriter.security({
|
||||
isHtml: isHtml(this.incomingRes),
|
||||
useAstSourceRewriting: this.config.experimentalSourceRewriting,
|
||||
url: this.req.proxiedUrl,
|
||||
deferSourceMapRewrite: this.deferSourceMapRewrite,
|
||||
})).on('error', this.onError)
|
||||
|
||||
this.next()
|
||||
}
|
||||
|
||||
|
||||
38
packages/proxy/lib/http/util/ast-rewriter.ts
Normal file
38
packages/proxy/lib/http/util/ast-rewriter.ts
Normal file
@@ -0,0 +1,38 @@
|
||||
import { HtmlJsRewriter, rewriteHtmlJsAsync, rewriteJsAsync } from '@packages/rewriter'
|
||||
import duplexify from 'duplexify'
|
||||
import { concatStream } from '@packages/network'
|
||||
import stream from 'stream'
|
||||
import { SecurityOpts } from './rewriter'
|
||||
|
||||
const pumpify = require('pumpify')
|
||||
const utf8Stream = require('utf8-stream')
|
||||
|
||||
export const strip = async (source: string, opts: SecurityOpts) => {
|
||||
if (opts.isHtml) {
|
||||
return rewriteHtmlJsAsync(opts.url, source, opts.deferSourceMapRewrite) // threaded
|
||||
}
|
||||
|
||||
return rewriteJsAsync(opts.url, source, opts.deferSourceMapRewrite) // threaded
|
||||
}
|
||||
|
||||
export const stripStream = (opts: SecurityOpts) => {
|
||||
if (opts.isHtml) {
|
||||
return pumpify(
|
||||
utf8Stream(),
|
||||
HtmlJsRewriter(opts.url, opts.deferSourceMapRewrite), // non-threaded
|
||||
)
|
||||
}
|
||||
|
||||
const pt = new (stream.PassThrough)()
|
||||
|
||||
return duplexify(
|
||||
pumpify(
|
||||
utf8Stream(),
|
||||
concatStream(async (body) => {
|
||||
pt.write(await strip(body.toString(), opts))
|
||||
pt.end()
|
||||
}),
|
||||
),
|
||||
pt,
|
||||
)
|
||||
}
|
||||
@@ -1,33 +1,60 @@
|
||||
import * as inject from './inject'
|
||||
import { strip, stripStream } from './security'
|
||||
import * as astRewriter from './ast-rewriter'
|
||||
import * as regexRewriter from './regex-rewriter'
|
||||
|
||||
export type SecurityOpts = {
|
||||
isHtml?: boolean
|
||||
url: string
|
||||
useAstSourceRewriting: boolean
|
||||
deferSourceMapRewrite: (opts: any) => string
|
||||
}
|
||||
|
||||
export type InjectionOpts = {
|
||||
domainName: string
|
||||
wantsInjection: WantsInjection
|
||||
wantsSecurityRemoved: any
|
||||
}
|
||||
|
||||
const doctypeRe = /(<\!doctype.*?>)/i
|
||||
const headRe = /(<head(?!er).*?>)/i
|
||||
const bodyRe = /(<body.*?>)/i
|
||||
const htmlRe = /(<html.*?>)/i
|
||||
|
||||
export function html (html: string, domainName: string, wantsInjection, wantsSecurityRemoved) {
|
||||
type WantsInjection = 'full' | 'partial' | false
|
||||
|
||||
function getRewriter (useAstSourceRewriting: boolean) {
|
||||
return useAstSourceRewriting ? astRewriter : regexRewriter
|
||||
}
|
||||
|
||||
function getHtmlToInject ({ domainName, wantsInjection }: InjectionOpts) {
|
||||
switch (wantsInjection) {
|
||||
case 'full':
|
||||
return inject.full(domainName)
|
||||
case 'partial':
|
||||
return inject.partial(domainName)
|
||||
default:
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
export async function html (html: string, opts: SecurityOpts & InjectionOpts) {
|
||||
const replace = (re, str) => {
|
||||
return html.replace(re, str)
|
||||
}
|
||||
|
||||
const htmlToInject = (() => {
|
||||
switch (wantsInjection) {
|
||||
case 'full':
|
||||
return inject.full(domainName)
|
||||
case 'partial':
|
||||
return inject.partial(domainName)
|
||||
default:
|
||||
return
|
||||
}
|
||||
})()
|
||||
const htmlToInject = getHtmlToInject(opts)
|
||||
|
||||
// strip clickjacking and framebusting
|
||||
// from the HTML if we've been told to
|
||||
if (wantsSecurityRemoved) {
|
||||
html = strip(html)
|
||||
if (opts.wantsSecurityRemoved) {
|
||||
html = await Promise.resolve(getRewriter(opts.useAstSourceRewriting).strip(html, opts))
|
||||
}
|
||||
|
||||
if (!htmlToInject) {
|
||||
return html
|
||||
}
|
||||
|
||||
// TODO: move this into regex-rewriting and have ast-rewriting handle this in its own way
|
||||
switch (false) {
|
||||
case !headRe.test(html):
|
||||
return replace(headRe, `$1 ${htmlToInject}`)
|
||||
@@ -47,4 +74,6 @@ export function html (html: string, domainName: string, wantsInjection, wantsSec
|
||||
}
|
||||
}
|
||||
|
||||
export const security = stripStream
|
||||
export function security (opts: SecurityOpts) {
|
||||
return getRewriter(opts.useAstSourceRewriting).stripStream(opts)
|
||||
}
|
||||
|
||||
@@ -17,6 +17,10 @@ export class NetworkProxy {
|
||||
this.http.handle(req, res)
|
||||
}
|
||||
|
||||
handleSourceMapRequest (req, res) {
|
||||
this.http.handleSourceMapRequest(req, res)
|
||||
}
|
||||
|
||||
setHttpBuffer (buffer) {
|
||||
this.http.setBuffer(buffer)
|
||||
}
|
||||
|
||||
@@ -4,7 +4,7 @@ import { expect } from 'chai'
|
||||
import fs from 'fs'
|
||||
import Promise from 'bluebird'
|
||||
import rp from '@cypress/request-promise'
|
||||
import * as security from '../../../../lib/http/util/security'
|
||||
import * as regexRewriter from '../../../../lib/http/util/regex-rewriter'
|
||||
|
||||
const original = `\
|
||||
<html>
|
||||
@@ -168,10 +168,10 @@ const expected = `\
|
||||
</html>\
|
||||
`
|
||||
|
||||
describe('http/util/security', () => {
|
||||
describe('http/util/regex-rewriter', () => {
|
||||
context('.strip', () => {
|
||||
it('replaces obstructive code', () => {
|
||||
expect(security.strip(original)).to.eq(expected)
|
||||
expect(regexRewriter.strip(original)).to.eq(expected)
|
||||
})
|
||||
|
||||
it('replaces jira window getter', () => {
|
||||
@@ -207,17 +207,17 @@ while (!isTopMostWindow(parentOf) && satisfiesSameOrigin(parentOf.parent)) {
|
||||
}\
|
||||
`
|
||||
|
||||
expect(security.strip(jira)).to.eq(`\
|
||||
expect(regexRewriter.strip(jira)).to.eq(`\
|
||||
for (; !function (n) {
|
||||
return n === n.parent || n.parent.__Cypress__
|
||||
}(n)\
|
||||
`)
|
||||
|
||||
expect(security.strip(jira2)).to.eq(`\
|
||||
expect(regexRewriter.strip(jira2)).to.eq(`\
|
||||
function(n){for(;!function(l){return l===l.parent || l.parent.__Cypress__}(l)&&function(l){try{if(void 0==l.location.href)return!1}catch(l){return!1}return!0}(l.parent);)l=l.parent;return l}\
|
||||
`)
|
||||
|
||||
expect(security.strip(jira3)).to.eq(`\
|
||||
expect(regexRewriter.strip(jira3)).to.eq(`\
|
||||
function satisfiesSameOrigin(w) {
|
||||
try {
|
||||
// Accessing location.href from a window on another origin will throw an exception.
|
||||
@@ -317,8 +317,8 @@ while (!isTopMostWindow(parentOf) && satisfiesSameOrigin(parentOf.parent)) {
|
||||
fs.readFile(pathToLib, 'utf8', cb)
|
||||
})
|
||||
.catch(downloadFile)
|
||||
.then((libCode) => {
|
||||
let stripped = security.strip(libCode)
|
||||
.then((libCode: string) => {
|
||||
let stripped = regexRewriter.strip(libCode)
|
||||
// nothing should have changed!
|
||||
|
||||
// TODO: this is currently failing but we're
|
||||
@@ -348,7 +348,7 @@ while (!isTopMostWindow(parentOf) && satisfiesSameOrigin(parentOf.parent)) {
|
||||
it('replaces obstructive code', (done) => {
|
||||
const haystacks = original.split('\n')
|
||||
|
||||
const replacer = security.stripStream()
|
||||
const replacer = regexRewriter.stripStream()
|
||||
|
||||
replacer.pipe(concatStream({ encoding: 'string' }, (str) => {
|
||||
const string = str.toString().trim()
|
||||
15
packages/rewriter/README.md
Normal file
15
packages/rewriter/README.md
Normal file
@@ -0,0 +1,15 @@
|
||||
# rewriter
|
||||
|
||||
This package contains logic for rewriting JS/HTML that flows through the Cypress proxy.
|
||||
|
||||
## Testing
|
||||
|
||||
Tests are located in [`./test`](./test)
|
||||
|
||||
To run tests:
|
||||
|
||||
```shell
|
||||
yarn test
|
||||
```
|
||||
|
||||
Additionally, the `server` and `proxy` packages contain integration tests that exercise the `rewriter`.
|
||||
@@ -0,0 +1,27 @@
|
||||
exports['DeferredSourceMapCache #resolve sourcemap generation for JS with no original sourcemap 1'] = {
|
||||
"version": 3,
|
||||
"sources": [
|
||||
"bar (original)"
|
||||
],
|
||||
"names": [],
|
||||
"mappings": "AAAA,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC",
|
||||
"file": "bar (original).map",
|
||||
"sourceRoot": ".",
|
||||
"sourcesContent": [
|
||||
"console.log()"
|
||||
]
|
||||
}
|
||||
|
||||
exports['composed sourcemap'] = {
|
||||
"version": 3,
|
||||
"sources": [
|
||||
"test.coffee"
|
||||
],
|
||||
"names": [],
|
||||
"mappings": ";AAAA,CAAA,CAAA,CAAA,CAAA,CAAA,CAAA,CAAA,CAAA,CAAA,CAAA,EAAA;EAAA,CAAA,EAAA,CAAA,CAAA,CAAA,EAAA,CAAA,EAAA,CAAA,CAAA,CAAA,CAAA,CAAA,CAAA,EAAA,CAAA,EAAA,CAAA,CAAA,CAAA,CAAA,CAAA,CAAA,CAAA,CAAA,CAAA,CAAA,CAAA,CAAA,EAAA,CAAA,EAAA,CAAA,CAAA,CAAA,CAAA,CAAA,CAAA,CAAA,CAAA,EAAA;;;EAIA,CAAA,CAAA,CAAA,CAAA,CAAA,CAAA,CAAA,CAAA,CAAA,CAAA,CAAW,CAAA,CAAA,CAAA,CAAA,CAAA,CAAA,CAAA,CAAA,CAAA,EAAA;IACT,CAAA,CAAA,CAAA,CAAA,CAAA,CAAA;IAAA,CAAA,CAAA,EAAA,CAAA,CAAA,CAAA;WACA,CAAA,CAAA,EAAA,EAAM,CAAA,CAAA,2DAAG,CAAA,CAAA,CAAA,CAAA,CAAA,SAAU,CAAC,CAAA,CAAA,CAAd,CAAA;EAFG,CAAX,EAGE,CAAA,CAAA,CAAA,CAHF,CAAA",
|
||||
"file": "foo.js (original).map",
|
||||
"sourceRoot": "http://somedomain.net/dir",
|
||||
"sourcesContent": [
|
||||
"# just an example of transpilation w/ sourcemap -\n# `test.coffee` is not directly transpiled/executed by any test code\n# regenerate JS + sourcemap with `coffee -c -m test.coffee`\n\nsetTimeout ->\n window\n foo = \"#{window.top.foo}\"\n, 1000\n"
|
||||
]
|
||||
}
|
||||
35
packages/rewriter/__snapshots__/html-spec.ts.js
Normal file
35
packages/rewriter/__snapshots__/html-spec.ts.js
Normal file
File diff suppressed because one or more lines are too long
18
packages/rewriter/__snapshots__/js-spec.ts.js
Normal file
18
packages/rewriter/__snapshots__/js-spec.ts.js
Normal file
@@ -0,0 +1,18 @@
|
||||
exports['js rewriter .rewriteJs source maps emits sourceInfo as expected 1'] = {
|
||||
"url": "http://example.com/foo.js",
|
||||
"js": "window.top"
|
||||
}
|
||||
|
||||
exports['js rewriter .rewriteJs source maps emits info about existing inline sourcemap 1'] = {
|
||||
"url": "http://example.com/foo.js",
|
||||
"js": "// Generated by CoffeeScript 2.2.1\n(function() {\n // just an example of transpilation w/ sourcemap -\n // `test.coffee` is not directly transpiled/executed by any test code\n // regenerate JS + sourcemap with `coffee -c -m test.coffee`\n setTimeout(function() {\n window;\n var foo;\n return foo = `${window.top.foo}`;\n }, 1000);\n\n}).call(this);\n\n//# sourceMappingURL=data:application/json;base64,ewogICJ2ZXJzaW9uIjogMywKICAiZmlsZSI6ICJ0ZXN0LmpzIiwKICAic291cmNlUm9vdCI6ICIiLAogICJzb3VyY2VzIjogWwogICAgInRlc3QuY29mZmVlIgogIF0sCiAgIm5hbWVzIjogW10sCiAgIm1hcHBpbmdzIjogIjtBQUFBO0VBQUE7OztFQUlBLFVBQUEsQ0FBVyxRQUFBLENBQUEsQ0FBQTtJQUNUO0FBQUEsUUFBQTtXQUNBLEdBQUEsR0FBTSxDQUFBLENBQUEsQ0FBRyxNQUFNLENBQUMsR0FBRyxDQUFDLEdBQWQsQ0FBQTtFQUZHLENBQVgsRUFHRSxJQUhGO0FBSkEiLAogICJzb3VyY2VzQ29udGVudCI6IFsKICAgICIjIGp1c3QgYW4gZXhhbXBsZSBvZiB0cmFuc3BpbGF0aW9uIHcvIHNvdXJjZW1hcCAtXG4jIGB0ZXN0LmNvZmZlZWAgaXMgbm90IGRpcmVjdGx5IHRyYW5zcGlsZWQvZXhlY3V0ZWQgYnkgYW55IHRlc3QgY29kZVxuIyByZWdlbmVyYXRlIEpTICsgc291cmNlbWFwIHdpdGggYGNvZmZlZSAtYyAtbSB0ZXN0LmNvZmZlZWBcblxuc2V0VGltZW91dCAtPlxuICB3aW5kb3dcbiAgZm9vID0gXCIje3dpbmRvdy50b3AuZm9vfVwiXG4sIDEwMDBcbiIKICBdCn0="
|
||||
}
|
||||
|
||||
exports['js rewriter .rewriteJs source maps emits info about existing external sourcemap 1'] = {
|
||||
"url": "http://example.com/foo.js",
|
||||
"js": "// Generated by CoffeeScript 2.2.1\n(function() {\n // just an example of transpilation w/ sourcemap -\n // `test.coffee` is not directly transpiled/executed by any test code\n // regenerate JS + sourcemap with `coffee -c -m test.coffee`\n setTimeout(function() {\n window;\n var foo;\n return foo = `${window.top.foo}`;\n }, 1000);\n\n}).call(this);\n\n//# sourceMappingURL=test.js.map\n"
|
||||
}
|
||||
|
||||
exports['js rewriter .rewriteJs transformations throws an error via the driver if AST visiting throws an error 1'] = `
|
||||
window.top.Cypress.utils.throwErrByPath('proxy.js_rewriting_failed', { args: {"errMessage":"foo","errStack":"stack","url":"http://example.com/foo.js"} })
|
||||
`
|
||||
5
packages/rewriter/index.js
Normal file
5
packages/rewriter/index.js
Normal file
@@ -0,0 +1,5 @@
|
||||
if (process.env.CYPRESS_ENV !== 'production') {
|
||||
require('@packages/ts/register')
|
||||
}
|
||||
|
||||
module.exports = require('./lib')
|
||||
32
packages/rewriter/lib/async-rewriters.ts
Normal file
32
packages/rewriter/lib/async-rewriters.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
import { queueRewriting } from './threads'
|
||||
import { DeferSourceMapRewriteFn } from './js'
|
||||
|
||||
// these functions are not included in `./js` or `./html` because doing so
|
||||
// would mean that `./threads/worker` would unnecessarily end up loading in the
|
||||
// `./threads` module for each worker
|
||||
|
||||
export function rewriteHtmlJsAsync (url: string, html: string, deferSourceMapRewrite: DeferSourceMapRewriteFn): Promise<string> {
|
||||
return queueRewriting({
|
||||
url,
|
||||
deferSourceMapRewrite,
|
||||
source: html,
|
||||
isHtml: true,
|
||||
})
|
||||
}
|
||||
|
||||
export function rewriteJsAsync (url: string, js: string, deferSourceMapRewrite: DeferSourceMapRewriteFn): Promise<string> {
|
||||
return queueRewriting({
|
||||
url,
|
||||
deferSourceMapRewrite,
|
||||
source: js,
|
||||
})
|
||||
}
|
||||
|
||||
export function rewriteJsSourceMapAsync (url: string, js: string, inputSourceMap: any): Promise<string> {
|
||||
return queueRewriting({
|
||||
url,
|
||||
inputSourceMap,
|
||||
sourceMap: true,
|
||||
source: js,
|
||||
})
|
||||
}
|
||||
127
packages/rewriter/lib/deferred-source-map-cache.ts
Normal file
127
packages/rewriter/lib/deferred-source-map-cache.ts
Normal file
@@ -0,0 +1,127 @@
|
||||
import _ from 'lodash'
|
||||
import Debug from 'debug'
|
||||
import { rewriteJsSourceMapAsync } from './async-rewriters'
|
||||
import * as sourceMaps from './util/source-maps'
|
||||
import url from 'url'
|
||||
|
||||
const debug = Debug('cypress:rewriter:deferred-source-map-cache')
|
||||
|
||||
export type DeferredSourceMapRequest = {
|
||||
uniqueId: string
|
||||
url: string
|
||||
js?: string
|
||||
sourceMap?: any
|
||||
resHeaders?: any
|
||||
}
|
||||
|
||||
const caseInsensitiveGet = (obj, lowercaseProperty) => {
|
||||
for (let key of Object.keys(obj)) {
|
||||
if (key.toLowerCase() === lowercaseProperty) {
|
||||
return obj[key]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const getSourceMapHeader = (headers) => {
|
||||
// sourcemap has precedence
|
||||
// @see https://searchfox.org/mozilla-central/rev/dc4560dcaafd79375b9411fdbbaaebb0a59a93ac/devtools/shared/DevToolsUtils.js#611-619
|
||||
return caseInsensitiveGet(headers, 'sourcemap') || caseInsensitiveGet(headers, 'x-sourcemap')
|
||||
}
|
||||
|
||||
/**
|
||||
* Holds on to data necessary to rewrite user JS to maybe generate a sourcemap at a later time,
|
||||
* potentially composed with the user's own sourcemap if one is present.
|
||||
*
|
||||
* The purpose of this is to avoid wasting CPU time and network I/O on generating, composing, and
|
||||
* sending a sourcemap along with every single rewritten JS snippet, since the source maps are
|
||||
* going to be unused and discarded most of the time.
|
||||
*/
|
||||
export class DeferredSourceMapCache {
|
||||
_idCounter = 0
|
||||
requests: DeferredSourceMapRequest[] = []
|
||||
requestLib: any
|
||||
|
||||
constructor (requestLib) {
|
||||
this.requestLib = requestLib
|
||||
}
|
||||
|
||||
defer = (request: DeferredSourceMapRequest) => {
|
||||
if (this._getRequestById(request.uniqueId)) {
|
||||
// prevent duplicate uniqueIds from ever existing
|
||||
throw new Error(`Deferred sourcemap key "${request.uniqueId}" is not unique`)
|
||||
}
|
||||
|
||||
// remove existing requests for this URL since they will not be loaded again
|
||||
this._removeRequestsByUrl(request.url)
|
||||
|
||||
this.requests.push(request)
|
||||
}
|
||||
|
||||
_removeRequestsByUrl (url: string) {
|
||||
_.remove(this.requests, { url })
|
||||
}
|
||||
|
||||
_getRequestById (uniqueId: string) {
|
||||
return _.find(this.requests, { uniqueId })
|
||||
}
|
||||
|
||||
async _getInputSourceMap (request: DeferredSourceMapRequest, headers: any) {
|
||||
// prefer inline sourceMappingURL over headers
|
||||
const sourceMapUrl = sourceMaps.getMappingUrl(request.js!) || getSourceMapHeader(request.resHeaders)
|
||||
|
||||
if (!sourceMapUrl) {
|
||||
return
|
||||
}
|
||||
|
||||
// try to decode it as a base64 string
|
||||
const inline = sourceMaps.tryDecodeInlineUrl(sourceMapUrl)
|
||||
|
||||
if (inline) {
|
||||
return inline
|
||||
}
|
||||
|
||||
// try to load it from the web
|
||||
const req = {
|
||||
url: url.resolve(request.url, sourceMapUrl),
|
||||
// TODO: this assumes that the sourcemap is on the same base domain, so it's safe to send the same headers
|
||||
// the browser sent for this sourcemap request - but if sourcemap is on a different domain, this will not
|
||||
// be true. need to use browser's cookiejar instead.
|
||||
headers,
|
||||
timeout: 5000,
|
||||
}
|
||||
|
||||
try {
|
||||
const { body } = await this.requestLib(req, true)
|
||||
|
||||
return body
|
||||
} catch (error) {
|
||||
// eslint-disable-next-line no-console
|
||||
debug('got an error loading user-provided sourcemap, serving proxy-generated sourcemap only %o', { url: request.url, headers, error })
|
||||
}
|
||||
}
|
||||
|
||||
async resolve (uniqueId: string, headers: any) {
|
||||
const request = this._getRequestById(uniqueId)
|
||||
|
||||
if (!request) {
|
||||
throw new Error(`Missing request with ID '${uniqueId}'`)
|
||||
}
|
||||
|
||||
if (request.sourceMap) {
|
||||
return request.sourceMap
|
||||
}
|
||||
|
||||
if (!request.js) {
|
||||
throw new Error('Missing JS for source map rewrite')
|
||||
}
|
||||
|
||||
const inputSourceMap = await this._getInputSourceMap(request, headers)
|
||||
|
||||
// cache the sourceMap so we don't need to regenerate it
|
||||
request.sourceMap = await rewriteJsSourceMapAsync(request.url, request.js, inputSourceMap)
|
||||
delete request.js // won't need this again
|
||||
delete request.resHeaders
|
||||
|
||||
return request.sourceMap
|
||||
}
|
||||
}
|
||||
55
packages/rewriter/lib/html-rules.ts
Normal file
55
packages/rewriter/lib/html-rules.ts
Normal file
@@ -0,0 +1,55 @@
|
||||
import find from 'lodash/find'
|
||||
import RewritingStream from 'parse5-html-rewriting-stream'
|
||||
import * as js from './js'
|
||||
|
||||
export function install (url: string, rewriter: RewritingStream, deferSourceMapRewrite?: js.DeferSourceMapRewriteFn) {
|
||||
let currentlyInsideJsScriptTag = false
|
||||
let inlineJsIndex = 0
|
||||
|
||||
rewriter.on('startTag', (startTag, raw) => {
|
||||
if (startTag.tagName !== 'script') {
|
||||
currentlyInsideJsScriptTag = false
|
||||
|
||||
return rewriter.emitRaw(raw)
|
||||
}
|
||||
|
||||
const typeAttr = find(startTag.attrs, { name: 'type' })
|
||||
|
||||
if (typeAttr && typeAttr.value !== 'text/javascript' && typeAttr.value !== 'module') {
|
||||
// we don't care about intercepting non-JS <script> tags
|
||||
currentlyInsideJsScriptTag = false
|
||||
|
||||
return rewriter.emitRaw(raw)
|
||||
}
|
||||
|
||||
currentlyInsideJsScriptTag = true
|
||||
|
||||
// rename subresource integrity attr since cypress's rewriting will invalidate SRI hashes
|
||||
// @see https://github.com/cypress-io/cypress/issues/2393
|
||||
const sriAttr = find(startTag.attrs, { name: 'integrity' })
|
||||
|
||||
if (sriAttr) {
|
||||
sriAttr.name = 'cypress:stripped-integrity'
|
||||
}
|
||||
|
||||
return rewriter.emitStartTag(startTag)
|
||||
})
|
||||
|
||||
rewriter.on('endTag', (_endTag, raw) => {
|
||||
currentlyInsideJsScriptTag = false
|
||||
|
||||
return rewriter.emitRaw(raw)
|
||||
})
|
||||
|
||||
rewriter.on('text', (_textToken, raw) => {
|
||||
if (!currentlyInsideJsScriptTag) {
|
||||
return rewriter.emitRaw(raw)
|
||||
}
|
||||
|
||||
// rewrite inline JS in <script> tags
|
||||
// create a unique filename per inline script
|
||||
const fakeJsUrl = [url, inlineJsIndex++].join(':')
|
||||
|
||||
return rewriter.emitRaw(js.rewriteJs(fakeJsUrl, raw, deferSourceMapRewrite))
|
||||
})
|
||||
}
|
||||
28
packages/rewriter/lib/html.ts
Normal file
28
packages/rewriter/lib/html.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
import RewritingStream from 'parse5-html-rewriting-stream'
|
||||
import * as htmlRules from './html-rules'
|
||||
import stream from 'stream'
|
||||
import { DeferSourceMapRewriteFn } from './js'
|
||||
|
||||
// the HTML rewriter passes inline JS to the JS rewriter, hence
|
||||
// the lack of basic `rewriteHtml` or `HtmlRewriter` exports here
|
||||
|
||||
export function HtmlJsRewriter (url: string, deferSourceMapRewrite?: DeferSourceMapRewriteFn): stream.Transform {
|
||||
const rewriter = new RewritingStream()
|
||||
|
||||
htmlRules.install(url, rewriter, deferSourceMapRewrite)
|
||||
|
||||
return rewriter
|
||||
}
|
||||
|
||||
export function rewriteHtmlJs (url: string, html: string, deferSourceMapRewrite?: DeferSourceMapRewriteFn): string {
|
||||
let out = ''
|
||||
const rewriter = HtmlJsRewriter(url, deferSourceMapRewrite)
|
||||
|
||||
rewriter.on('data', (chunk) => {
|
||||
out += chunk
|
||||
})
|
||||
|
||||
rewriter.end(html)
|
||||
|
||||
return out
|
||||
}
|
||||
7
packages/rewriter/lib/index.ts
Normal file
7
packages/rewriter/lib/index.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
export { HtmlJsRewriter } from './html'
|
||||
|
||||
export { rewriteJsAsync, rewriteHtmlJsAsync } from './async-rewriters'
|
||||
|
||||
export { DeferredSourceMapCache } from './deferred-source-map-cache'
|
||||
|
||||
export { createInitialWorkers, terminateAllWorkers } from './threads'
|
||||
200
packages/rewriter/lib/js-rules.ts
Normal file
200
packages/rewriter/lib/js-rules.ts
Normal file
@@ -0,0 +1,200 @@
|
||||
import {
|
||||
Visitor,
|
||||
namedTypes as n,
|
||||
builders as b,
|
||||
} from 'ast-types'
|
||||
import { ExpressionKind } from 'ast-types/gen/kinds'
|
||||
|
||||
// use `globalThis` instead of `window`, `self`... to lower chances of scope conflict
|
||||
// users can technically override even this, but it would be very rude
|
||||
// "[globalThis] provides a way for polyfills/shims, build tools, and portable code to have a reliable non-eval means to access the global..."
|
||||
// @see https://github.com/tc39/proposal-global/blob/master/NAMING.md
|
||||
const globalIdentifier = b.identifier('globalThis')
|
||||
|
||||
/**
|
||||
* Generate a CallExpression for Cypress.resolveWindowReference
|
||||
* @param accessedObject object being accessed
|
||||
* @param prop name of property being accessed
|
||||
* @param maybeVal if an assignment is being made, this is the RHS of the assignment
|
||||
*/
|
||||
function resolveWindowReference (accessedObject: ExpressionKind, prop: string, maybeVal?: ExpressionKind) {
|
||||
const args = [
|
||||
globalIdentifier,
|
||||
accessedObject,
|
||||
b.stringLiteral(prop),
|
||||
]
|
||||
|
||||
if (maybeVal) {
|
||||
args.push(maybeVal)
|
||||
}
|
||||
|
||||
return b.callExpression(
|
||||
b.memberExpression(
|
||||
b.memberExpression(
|
||||
b.memberExpression(
|
||||
globalIdentifier,
|
||||
b.identifier('top'),
|
||||
),
|
||||
b.identifier('Cypress'),
|
||||
),
|
||||
b.identifier('resolveWindowReference'),
|
||||
),
|
||||
args,
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a CallExpression for Cypress.resolveLocationReference
|
||||
*/
|
||||
function resolveLocationReference () {
|
||||
return b.callExpression(
|
||||
b.memberExpression(
|
||||
b.memberExpression(
|
||||
b.memberExpression(
|
||||
globalIdentifier,
|
||||
b.identifier('top'),
|
||||
),
|
||||
b.identifier('Cypress'),
|
||||
),
|
||||
b.identifier('resolveLocationReference'),
|
||||
),
|
||||
[globalIdentifier],
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Given an Identifier or a Literal, return a property name that should use `resolveWindowReference`.
|
||||
* @param node
|
||||
*/
|
||||
function getReplaceablePropOfMemberExpression (node: n.MemberExpression) {
|
||||
const { property } = node
|
||||
|
||||
// something.(top|parent)
|
||||
if (n.Identifier.check(property) && ['parent', 'top', 'location'].includes(property.name)) {
|
||||
return property.name
|
||||
}
|
||||
|
||||
// something['(top|parent)']
|
||||
if (n.Literal.check(property) && ['parent', 'top', 'location'].includes(String(property.value))) {
|
||||
return String(property.value)
|
||||
}
|
||||
|
||||
// NOTE: cases where a variable is used for the prop will not be replaced
|
||||
// for example, `bar = 'top'; window[bar];` will not be replaced
|
||||
// this would most likely be too slow
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
/**
|
||||
* An AST Visitor that applies JS transformations required for Cypress.
|
||||
* @see https://github.com/benjamn/ast-types#ast-traversal for details on how the Visitor is implemented
|
||||
* @see https://astexplorer.net/#/gist/7f1e645c74df845b0e1f814454e9bbdf/f443b701b53bf17fbbf40e9285cb8b65a4066240
|
||||
* to explore ASTs generated by recast
|
||||
*/
|
||||
export const jsRules: Visitor<{}> = {
|
||||
// replace member accesses like foo['top'] or bar.parent with resolveWindowReference
|
||||
visitMemberExpression (path) {
|
||||
const { node } = path
|
||||
|
||||
const prop = getReplaceablePropOfMemberExpression(node)
|
||||
|
||||
if (!prop) {
|
||||
return this.traverse(path)
|
||||
}
|
||||
|
||||
path.replace(resolveWindowReference(path.get('object').node, prop))
|
||||
|
||||
return false
|
||||
},
|
||||
// replace lone identifiers like `top`, `parent`, with resolveWindowReference
|
||||
visitIdentifier (path) {
|
||||
const { node } = path
|
||||
|
||||
if (path.parentPath) {
|
||||
const parentNode = path.parentPath.node
|
||||
|
||||
// like `identifer = 'foo'`
|
||||
const isAssignee = n.AssignmentExpression.check(parentNode) && parentNode.left === node
|
||||
|
||||
if (isAssignee && node.name === 'location') {
|
||||
// `location = 'something'`, rewrite to intercepted href setter since relative urls can break this
|
||||
path.replace(b.memberExpression(resolveLocationReference(), b.identifier('href')))
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
// some Identifiers do not refer to a scoped variable, depending on how they're used
|
||||
if (
|
||||
// like `var top = 'foo'`
|
||||
(n.VariableDeclarator.check(parentNode) && parentNode.id === node)
|
||||
|| (isAssignee)
|
||||
|| (
|
||||
[
|
||||
'LabeledStatement', // like `top: foo();`
|
||||
'ContinueStatement', // like 'continue top'
|
||||
'BreakStatement', // like 'break top'
|
||||
'Property', // like `{ top: 'foo' }`
|
||||
'FunctionDeclaration', // like `function top()`
|
||||
'RestElement', // like (...top)
|
||||
'ArrowFunctionExpression', // like `(top, ...parent) => { }`
|
||||
'ArrowExpression', // MDN Parser docs mention this being used for () => {}
|
||||
'FunctionExpression', // like `(function top())`,
|
||||
].includes(parentNode.type)
|
||||
)
|
||||
) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
if (path.scope.declares(node.name)) {
|
||||
// identifier has been declared in local scope, don't care about replacing
|
||||
return this.traverse(path)
|
||||
}
|
||||
|
||||
if (node.name === 'location') {
|
||||
path.replace(resolveLocationReference())
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
if (['parent', 'top'].includes(node.name)) {
|
||||
path.replace(resolveWindowReference(globalIdentifier, node.name))
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
this.traverse(path)
|
||||
},
|
||||
visitAssignmentExpression (path) {
|
||||
const { node } = path
|
||||
|
||||
const finish = () => {
|
||||
this.traverse(path)
|
||||
}
|
||||
|
||||
if (!n.MemberExpression.check(node.left)) {
|
||||
return finish()
|
||||
}
|
||||
|
||||
const propBeingSet = getReplaceablePropOfMemberExpression(node.left)
|
||||
|
||||
if (!propBeingSet) {
|
||||
return finish()
|
||||
}
|
||||
|
||||
if (node.operator !== '=') {
|
||||
// in the case of +=, -=, |=, etc., assume they're not doing something like
|
||||
// `window.top += 4` since that would be invalid anyways, just continue down the RHS
|
||||
this.traverse(path.get('right'))
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
const objBeingSetOn = node.left.object
|
||||
|
||||
path.replace(resolveWindowReference(objBeingSetOn, propBeingSet, node.right))
|
||||
|
||||
return false
|
||||
},
|
||||
}
|
||||
87
packages/rewriter/lib/js.ts
Normal file
87
packages/rewriter/lib/js.ts
Normal file
@@ -0,0 +1,87 @@
|
||||
import * as astTypes from 'ast-types'
|
||||
import Debug from 'debug'
|
||||
import { jsRules } from './js-rules'
|
||||
import * as recast from 'recast'
|
||||
import * as sourceMaps from './util/source-maps'
|
||||
|
||||
const debug = Debug('cypress:rewriter:js')
|
||||
|
||||
const defaultPrintOpts: recast.Options = {
|
||||
// will only affect reprinted quotes
|
||||
quote: 'single',
|
||||
}
|
||||
|
||||
type OriginalSourceInfo = { url: string, js: string }
|
||||
|
||||
function _generateDriverError (url: string, err: Error) {
|
||||
const args = JSON.stringify({
|
||||
errMessage: err.message,
|
||||
errStack: err.stack,
|
||||
url,
|
||||
})
|
||||
|
||||
return `window.top.Cypress.utils.throwErrByPath('proxy.js_rewriting_failed', { args: ${args} })`
|
||||
}
|
||||
|
||||
// a function that, given source info, returns an id that can be used to build the sourcemap later
|
||||
export type DeferSourceMapRewriteFn = (sourceInfo: OriginalSourceInfo) => string
|
||||
|
||||
export function rewriteJsSourceMap (url: string, js: string, inputSourceMap: any): any {
|
||||
try {
|
||||
const { sourceFileName, sourceMapName, sourceRoot } = sourceMaps.getPaths(url)
|
||||
|
||||
const ast = recast.parse(js, { sourceFileName })
|
||||
|
||||
astTypes.visit(ast, jsRules)
|
||||
|
||||
return recast.print(ast, {
|
||||
inputSourceMap,
|
||||
sourceMapName,
|
||||
sourceRoot,
|
||||
...defaultPrintOpts,
|
||||
}).map
|
||||
} catch (err) {
|
||||
debug('error while parsing JS %o', { err, js: js.slice ? js.slice(0, 500) : js })
|
||||
|
||||
return { err }
|
||||
}
|
||||
}
|
||||
|
||||
export function _rewriteJsUnsafe (url: string, js: string, deferSourceMapRewrite?: DeferSourceMapRewriteFn): string {
|
||||
const ast = recast.parse(js)
|
||||
|
||||
try {
|
||||
astTypes.visit(ast, jsRules)
|
||||
} catch (err) {
|
||||
// if visiting fails, it points to a bug in our rewriting logic, so raise the error to the driver
|
||||
return _generateDriverError(url, err)
|
||||
}
|
||||
|
||||
const { code } = recast.print(ast, defaultPrintOpts)
|
||||
|
||||
if (!deferSourceMapRewrite) {
|
||||
// no sourcemaps
|
||||
return sourceMaps.stripMappingUrl(code)
|
||||
}
|
||||
|
||||
// get an ID that can be used to lazy-generate the source map later
|
||||
const sourceMapId = deferSourceMapRewrite({ url, js })
|
||||
|
||||
return sourceMaps.urlFormatter(
|
||||
// using a relative URL ensures that required cookies + other headers are sent along
|
||||
// and can be reused if the user's sourcemap requires an HTTP request to be made
|
||||
`/__cypress/source-maps/${sourceMapId}.map`,
|
||||
code,
|
||||
)
|
||||
}
|
||||
|
||||
export function rewriteJs (url: string, js: string, deferSourceMapRewrite?: DeferSourceMapRewriteFn): string {
|
||||
try {
|
||||
// rewriting can throw on invalid JS or if there are bugs in the js-rules, so always wrap it
|
||||
return _rewriteJsUnsafe(url, js, deferSourceMapRewrite)
|
||||
} catch (err) {
|
||||
debug('error while parsing JS %o', { err, js: js.slice ? js.slice(0, 500) : js })
|
||||
|
||||
return js
|
||||
}
|
||||
}
|
||||
249
packages/rewriter/lib/threads/index.ts
Normal file
249
packages/rewriter/lib/threads/index.ts
Normal file
@@ -0,0 +1,249 @@
|
||||
import _ from 'lodash'
|
||||
import Bluebird from 'bluebird'
|
||||
import Debug from 'debug'
|
||||
import * as path from 'path'
|
||||
import os from 'os'
|
||||
import { MessageChannel, Worker } from 'worker_threads'
|
||||
import { RewriteRequest, RewriteResponse } from './types'
|
||||
import { DeferSourceMapRewriteFn } from '../js'
|
||||
|
||||
const debug = Debug('cypress:rewriter:threads')
|
||||
|
||||
const _debugWorker = !debug.enabled ? _.noop : (worker: WorkerInfo) => {
|
||||
return { ..._.pick(worker, 'isBusy', 'id'), freeWorkers: _.filter(workers, { isBusy: false }).length }
|
||||
}
|
||||
|
||||
const _debugOpts = !debug.enabled ? _.noop : (opts: RewriteOpts) => {
|
||||
return { ..._.pick(opts, 'isHtml'), sourceLength: opts.source.length }
|
||||
}
|
||||
|
||||
// in production, it is preferable to use the transpiled version of `worker.ts`
|
||||
// because it does not require importing @packages/ts like development does.
|
||||
// this has a huge performance impact, bringing the `responsiveMs` for threads
|
||||
// from ~1s to about ~300ms on my system
|
||||
const WORKER_FILENAME = process.env.CYPRESS_INTERNAL_ENV === 'production' ? 'worker.js' : 'worker-shim.js'
|
||||
|
||||
const WORKER_PATH = path.join(__dirname, WORKER_FILENAME)
|
||||
|
||||
// spawn up to `os.cpus().length` threads (default to 4 if this call fails)
|
||||
const MAX_WORKER_THREADS = _.get(os.cpus(), 'length') || 4
|
||||
|
||||
// spawn up to 4 threads at startup
|
||||
const INITIAL_WORKER_THREADS = Math.min(MAX_WORKER_THREADS, 4)
|
||||
|
||||
type DeferredPromise<T> = { p: Promise<T>, resolve: () => {}, reject: () => {} }
|
||||
|
||||
type WorkerInfo = {
|
||||
id: number
|
||||
thread: Worker
|
||||
isBusy: boolean
|
||||
}
|
||||
|
||||
type QueuedRewrite = {
|
||||
deferred: DeferredPromise<string>
|
||||
opts: RewriteOpts
|
||||
}
|
||||
|
||||
type RewriteOpts = Pick<RewriteRequest, 'url' | 'source' | 'isHtml' | 'sourceMap' | 'inputSourceMap'> & {
|
||||
deferSourceMapRewrite?: DeferSourceMapRewriteFn
|
||||
}
|
||||
|
||||
const workers: WorkerInfo[] = []
|
||||
const queued: QueuedRewrite[] = []
|
||||
|
||||
let originalProcessExit
|
||||
|
||||
// HACK: electron can SIGABRT if exiting while worker_threads are active, so overwrite process.exit
|
||||
// to ensure that all worker threads are killed *before* exiting.
|
||||
// @see https://github.com/electron/electron/issues/23366
|
||||
function wrapProcessExit () {
|
||||
if (originalProcessExit) {
|
||||
return
|
||||
}
|
||||
|
||||
originalProcessExit = process.exit
|
||||
|
||||
// note - process.exit is normally synchronous, so this could potentially cause strange behavior
|
||||
// @ts-ignore
|
||||
process.exit = _.once(async (...args) => {
|
||||
debug('intercepted process.exit called, closing worker threads')
|
||||
terminateAllWorkers()
|
||||
.delay(100)
|
||||
.finally(() => {
|
||||
debug('all workers terminated, exiting for real')
|
||||
originalProcessExit.call(process, ...args)
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
function createWorker () {
|
||||
const startedAt = Date.now()
|
||||
let onlineMs: number
|
||||
|
||||
const thread = new Worker(WORKER_PATH)
|
||||
.on('exit', (exitCode) => {
|
||||
debug('worker exited %o', { exitCode, worker: _debugWorker(worker) })
|
||||
_.remove(workers, worker)
|
||||
})
|
||||
.on('online', () => {
|
||||
onlineMs = Date.now() - startedAt
|
||||
})
|
||||
.on('message', () => {
|
||||
debug('received initial ready message from worker %o', {
|
||||
onlineMs, // time for JS to start executing
|
||||
responsiveMs: Date.now() - startedAt, // time for worker to be ready for commands
|
||||
worker: _debugWorker(worker),
|
||||
})
|
||||
})
|
||||
|
||||
const worker = {
|
||||
id: thread.threadId,
|
||||
isBusy: false,
|
||||
thread,
|
||||
}
|
||||
|
||||
workers.push(worker)
|
||||
|
||||
wrapProcessExit()
|
||||
|
||||
return worker
|
||||
}
|
||||
|
||||
export function createInitialWorkers () {
|
||||
// since workers take a little bit of time to start up (due to loading Node and `require`s),
|
||||
// performance can be gained by letting them start before user tests run
|
||||
if (workers.length > 0) {
|
||||
return
|
||||
}
|
||||
|
||||
_.times(INITIAL_WORKER_THREADS, createWorker)
|
||||
}
|
||||
|
||||
export function terminateAllWorkers () {
|
||||
return Bluebird.all([
|
||||
workers.map((worker) => worker.thread.terminate()),
|
||||
])
|
||||
}
|
||||
|
||||
async function sendRewrite (worker: WorkerInfo, opts: RewriteOpts): Promise<string> {
|
||||
const startedAt = Date.now()
|
||||
|
||||
debug('sending rewrite to worker %o', { worker: _debugWorker(worker), opts: _debugOpts(opts) })
|
||||
|
||||
if (worker.isBusy) {
|
||||
throw new Error('worker is already busy')
|
||||
}
|
||||
|
||||
worker.isBusy = true
|
||||
|
||||
if (!getFreeWorker() && workers.length < MAX_WORKER_THREADS) {
|
||||
// create a worker in anticipation of another rewrite coming in
|
||||
createWorker()
|
||||
}
|
||||
|
||||
const { port1, port2 } = new MessageChannel()
|
||||
|
||||
const req: RewriteRequest = {
|
||||
port: port1,
|
||||
..._.omit(opts, 'deferSourceMapRewrite'),
|
||||
}
|
||||
|
||||
worker.thread.postMessage(req, [req.port])
|
||||
|
||||
const code = await new Promise((resolve, reject) => {
|
||||
const onExit = (exitCode) => {
|
||||
reject(new Error(`worker exited with exit code ${exitCode}`))
|
||||
}
|
||||
|
||||
worker.thread.once('exit', onExit)
|
||||
worker.thread.once('error', reject)
|
||||
port2.on('message', (res: RewriteResponse) => {
|
||||
if (res.deferredSourceMap) {
|
||||
return opts.deferSourceMapRewrite!(res.deferredSourceMap)
|
||||
}
|
||||
|
||||
const totalMs = Date.now() - startedAt
|
||||
|
||||
debug('received response from worker %o', {
|
||||
error: res.error,
|
||||
totalMs: Date.now() - startedAt,
|
||||
threadMs: res.threadMs, // time taken to run rewriting in thread
|
||||
overheadMs: totalMs - res.threadMs, // time not accounted for by `threadMs`
|
||||
worker: _debugWorker(worker),
|
||||
opts: _debugOpts(opts),
|
||||
})
|
||||
|
||||
worker.thread.removeListener('exit', onExit)
|
||||
worker.thread.removeListener('error', reject)
|
||||
|
||||
if (res.error) {
|
||||
return reject(res.error)
|
||||
}
|
||||
|
||||
return resolve(res.output)
|
||||
})
|
||||
})
|
||||
.finally(() => {
|
||||
port2.close()
|
||||
worker.isBusy = false
|
||||
maybeRunNextInQueue()
|
||||
}) as Promise<string>
|
||||
|
||||
return code
|
||||
}
|
||||
|
||||
function maybeRunNextInQueue () {
|
||||
const next = queued.shift()
|
||||
|
||||
if (!next) {
|
||||
return
|
||||
}
|
||||
|
||||
debug('running next rewrite in queue', { opts: _debugOpts() })
|
||||
|
||||
queueRewriting(next.opts)
|
||||
.then(next.deferred.resolve)
|
||||
.catch(next.deferred.reject)
|
||||
}
|
||||
|
||||
function getFreeWorker (): WorkerInfo | undefined {
|
||||
return _.find(workers, { isBusy: false })
|
||||
}
|
||||
|
||||
export function queueRewriting (opts: RewriteOpts): Promise<string> {
|
||||
// if a worker is free now, use it
|
||||
const freeWorker = getFreeWorker()
|
||||
|
||||
if (freeWorker) {
|
||||
debug('sending source to free worker')
|
||||
|
||||
return sendRewrite(freeWorker, opts)
|
||||
}
|
||||
|
||||
// if there's room, create a new thread
|
||||
if (workers.length < MAX_WORKER_THREADS) {
|
||||
debug('creating new worker')
|
||||
const newWorker = createWorker()
|
||||
|
||||
return sendRewrite(newWorker, opts)
|
||||
}
|
||||
|
||||
// otherwise enqueue
|
||||
debug('enqueuing source for rewriting %o', { opts: _debugOpts(opts), prevQueueLength: queued.length })
|
||||
const deferred = getDeferredPromise()
|
||||
|
||||
queued.push({ opts, deferred })
|
||||
|
||||
return deferred.p
|
||||
}
|
||||
|
||||
function getDeferredPromise (): DeferredPromise<any> {
|
||||
let resolve; let reject
|
||||
|
||||
const p = new Promise((_resolve, _reject) => {
|
||||
resolve = _resolve
|
||||
reject = _reject
|
||||
})
|
||||
|
||||
return { p, resolve, reject }
|
||||
}
|
||||
33
packages/rewriter/lib/threads/types.ts
Normal file
33
packages/rewriter/lib/threads/types.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
import { MessagePort } from 'worker_threads'
|
||||
|
||||
export type RewriteRequest = {
|
||||
/**
|
||||
* used for resolving references in sourcemaps
|
||||
*/
|
||||
url: string
|
||||
port: MessagePort
|
||||
isHtml?: boolean
|
||||
source: string
|
||||
/**
|
||||
* If true, return the sourcemap and not the generated source.
|
||||
*/
|
||||
sourceMap?: true
|
||||
inputSourceMap?: string
|
||||
}
|
||||
|
||||
export type RewriteResponse = {
|
||||
threadMs: number
|
||||
/**
|
||||
* If set, this message is a notification that a source map may need to be generated for a given
|
||||
* JS snippet, and should be set aside for later.
|
||||
*/
|
||||
deferredSourceMap?: {
|
||||
url: string
|
||||
js: string
|
||||
}
|
||||
/**
|
||||
* If set, this is the final output of the rewrite in progress.
|
||||
*/
|
||||
output?: any
|
||||
error?: Error
|
||||
}
|
||||
6
packages/rewriter/lib/threads/worker-shim.js
Normal file
6
packages/rewriter/lib/threads/worker-shim.js
Normal file
@@ -0,0 +1,6 @@
|
||||
if (process.env.CYPRESS_INTERNAL_ENV === 'production') {
|
||||
throw new Error(`${__filename} should only run outside of prod`)
|
||||
}
|
||||
|
||||
require('@packages/ts/register')
|
||||
require('./worker.ts')
|
||||
65
packages/rewriter/lib/threads/worker.ts
Normal file
65
packages/rewriter/lib/threads/worker.ts
Normal file
@@ -0,0 +1,65 @@
|
||||
// this is designed to run as its own thread, managed by `threads.ts`
|
||||
// WARNING: take care to not over-import modules here - the upfront
|
||||
// mem/CPU cost is paid up to threads.MAX_WORKER_THREADS times
|
||||
|
||||
import { parentPort, isMainThread, threadId } from 'worker_threads'
|
||||
|
||||
if (isMainThread) {
|
||||
throw new Error(`${__filename} should only be run as a worker thread`)
|
||||
}
|
||||
|
||||
import { rewriteJs, rewriteJsSourceMap } from '../js'
|
||||
import { rewriteHtmlJs } from '../html'
|
||||
import { RewriteRequest, RewriteResponse } from './types'
|
||||
|
||||
parentPort!.postMessage(true)
|
||||
|
||||
let _idCounter = 0
|
||||
|
||||
parentPort!.on('message', (req: RewriteRequest) => {
|
||||
const startedAt = Date.now()
|
||||
|
||||
function _deferSourceMapRewrite (deferredSourceMap) {
|
||||
const uniqueId = [threadId, _idCounter++].join('.')
|
||||
|
||||
_reply({
|
||||
threadMs: _getThreadMs(),
|
||||
deferredSourceMap: {
|
||||
uniqueId,
|
||||
...deferredSourceMap,
|
||||
},
|
||||
})
|
||||
|
||||
return uniqueId
|
||||
}
|
||||
|
||||
function _reply (res: RewriteResponse) {
|
||||
req.port.postMessage(res)
|
||||
}
|
||||
|
||||
function _getThreadMs () {
|
||||
return Date.now() - startedAt
|
||||
}
|
||||
|
||||
function _getOutput () {
|
||||
if (req.isHtml) {
|
||||
return rewriteHtmlJs(req.url, req.source, _deferSourceMapRewrite)
|
||||
}
|
||||
|
||||
if (req.sourceMap) {
|
||||
return rewriteJsSourceMap(req.url, req.source, req.inputSourceMap)
|
||||
}
|
||||
|
||||
return rewriteJs(req.url, req.source, _deferSourceMapRewrite)
|
||||
}
|
||||
|
||||
try {
|
||||
const output = _getOutput()
|
||||
|
||||
_reply({ output, threadMs: _getThreadMs() })
|
||||
} catch (error) {
|
||||
_reply({ error, threadMs: _getThreadMs() })
|
||||
}
|
||||
|
||||
return req.port.close()
|
||||
})
|
||||
61
packages/rewriter/lib/util/source-maps.ts
Normal file
61
packages/rewriter/lib/util/source-maps.ts
Normal file
@@ -0,0 +1,61 @@
|
||||
import path from 'path'
|
||||
import url from 'url'
|
||||
|
||||
const sourceMapRe = /\n\/\/[ \t]*(?:#|@) sourceMappingURL=([^\s]+)\s*$/
|
||||
const dataUrlRe = /^data:application\/json;(?:charset=utf-8;)base64,([^\s]+)\s*$/
|
||||
|
||||
export const getMappingUrl = (js: string) => {
|
||||
const matches = sourceMapRe.exec(js)
|
||||
|
||||
if (matches) {
|
||||
return matches[1]
|
||||
}
|
||||
|
||||
return undefined
|
||||
}
|
||||
|
||||
export const stripMappingUrl = (js: string) => {
|
||||
return js.replace(sourceMapRe, '')
|
||||
}
|
||||
|
||||
export const tryDecodeInlineUrl = (url: string) => {
|
||||
const matches = dataUrlRe.exec(url)
|
||||
|
||||
if (matches) {
|
||||
try {
|
||||
const base64 = matches[1]
|
||||
|
||||
// theoretically we could capture the charset properly and use it in the toString call here
|
||||
// but it is unlikely that non-utf-8 charsets will be encountered in the wild, and handling all
|
||||
// possible charsets is complex
|
||||
return JSON.parse(Buffer.from(base64, 'base64').toString())
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const getPaths = (urlStr: string) => {
|
||||
try {
|
||||
const parsed = url.parse(urlStr, false)
|
||||
|
||||
// if the sourceFileName is the same as the real filename, Chromium appends a weird "? [sm]" suffix to the filename
|
||||
// avoid this by appending some text to the filename
|
||||
// @see https://cs.chromium.org/chromium/src/third_party/devtools-frontend/src/front_end/sdk/SourceMap.js?l=445-447&rcl=a0c450d5b58f71b67134306b2e1c29a75326d3db
|
||||
const sourceFileName = `${path.basename(parsed.path || '')} (original)`
|
||||
|
||||
parsed.pathname = path.dirname(parsed.pathname || '')
|
||||
delete parsed.search
|
||||
|
||||
return { sourceRoot: parsed.format(), sourceFileName, sourceMapName: `${sourceFileName}.map` }
|
||||
} catch {
|
||||
return { sourceRoot: undefined, sourceFileName: 'source.js', sourceMapName: 'source.js.map' }
|
||||
}
|
||||
}
|
||||
|
||||
export const urlFormatter = (url: string, js: string): string => {
|
||||
return [
|
||||
stripMappingUrl(js),
|
||||
`//# sourceMappingURL=${url}`,
|
||||
].join('\n')
|
||||
}
|
||||
42
packages/rewriter/package.json
Normal file
42
packages/rewriter/package.json
Normal file
@@ -0,0 +1,42 @@
|
||||
{
|
||||
"name": "@packages/rewriter",
|
||||
"version": "0.0.0",
|
||||
"private": true,
|
||||
"main": "index.js",
|
||||
"scripts": {
|
||||
"build-prod": "tsc --project .",
|
||||
"build-test": "yarn build-prod --noEmit",
|
||||
"clean-deps": "rm -rf node_modules",
|
||||
"test": "mocha --reporter mocha-multi-reporters --reporter-options configFile=../../mocha-reporter-config.json"
|
||||
},
|
||||
"dependencies": {
|
||||
"ast-types": "0.13.3",
|
||||
"bluebird": "3.7.2",
|
||||
"chai-as-promised": "7.1.1",
|
||||
"debug": "4.1.1",
|
||||
"lodash": "4.17.15",
|
||||
"parse5-html-rewriting-stream": "5.1.1",
|
||||
"recast": "0.18.8"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@cypress/request-promise": "4.2.6",
|
||||
"@types/parse5-html-rewriting-stream": "5.1.1",
|
||||
"fs-extra": "9.0.0",
|
||||
"nock": "12.0.3",
|
||||
"sinon": "9.0.2",
|
||||
"sinon-chai": "3.5.0",
|
||||
"snap-shot-it": "7.9.3"
|
||||
},
|
||||
"files": [
|
||||
"lib"
|
||||
],
|
||||
"types": "./lib/index.ts",
|
||||
"workspaces": {
|
||||
"nohoist": [
|
||||
"parse5-html-rewriting-stream",
|
||||
"parse5-html-rewriting-stream/**",
|
||||
"@types/parse5-html-rewriting-stream",
|
||||
"@types/parse5-html-rewriting-stream/**"
|
||||
]
|
||||
}
|
||||
}
|
||||
5
packages/rewriter/test/.eslintrc
Normal file
5
packages/rewriter/test/.eslintrc
Normal file
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"extends": [
|
||||
"plugin:@cypress/dev/tests"
|
||||
]
|
||||
}
|
||||
15
packages/rewriter/test/fixtures.ts
Normal file
15
packages/rewriter/test/fixtures.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
import fs from 'fs'
|
||||
import * as sourceMaps from '../lib/util/source-maps'
|
||||
|
||||
export const testSourceMap = fs.readFileSync(`${__dirname}/fixtures/test.js.map`).toString()
|
||||
|
||||
export const testSourceWithExternalSourceMap = fs.readFileSync(`${__dirname}/fixtures/test.js`).toString()
|
||||
|
||||
export const testSourceWithNoSourceMap = sourceMaps.stripMappingUrl(testSourceWithExternalSourceMap)
|
||||
|
||||
export const testSourceWithInlineSourceMap = sourceMaps.urlFormatter(
|
||||
`data:application/json;base64,${Buffer.from(testSourceMap).toString('base64')}`,
|
||||
testSourceWithNoSourceMap,
|
||||
)
|
||||
|
||||
export const testHtml = fs.readFileSync(`${__dirname}/fixtures/test.html`).toString()
|
||||
8
packages/rewriter/test/fixtures/test.coffee
vendored
Normal file
8
packages/rewriter/test/fixtures/test.coffee
vendored
Normal file
@@ -0,0 +1,8 @@
|
||||
# just an example of transpilation w/ sourcemap -
|
||||
# `test.coffee` is not directly transpiled/executed by any test code
|
||||
# regenerate JS + sourcemap with `coffee -c -m test.coffee`
|
||||
|
||||
setTimeout ->
|
||||
window
|
||||
foo = "#{window.top.foo}"
|
||||
, 1000
|
||||
62
packages/rewriter/test/fixtures/test.html
vendored
Normal file
62
packages/rewriter/test/fixtures/test.html
vendored
Normal file
@@ -0,0 +1,62 @@
|
||||
<html>
|
||||
<body>
|
||||
top1
|
||||
settop
|
||||
settopbox
|
||||
parent1
|
||||
grandparent
|
||||
grandparents
|
||||
topFoo
|
||||
topFoo.window
|
||||
topFoo.window != topFoo
|
||||
parentFoo
|
||||
parentFoo.window
|
||||
parentFoo.window != parentFoo
|
||||
|
||||
<div style="left: 1500px; top: 0px;"></div>
|
||||
<div style="left: 1500px; top : 0px;"></div>
|
||||
<div style="left: 1500px; top : 0px;"></div>
|
||||
|
||||
parent()
|
||||
foo.parent()
|
||||
top()
|
||||
foo.top()
|
||||
foo("parent")
|
||||
foo("top")
|
||||
|
||||
const parent = () => { bar: 'bar' }
|
||||
|
||||
parent.bar
|
||||
|
||||
<script type="text/javascript">
|
||||
if (top != self) run()
|
||||
if (top!=self) run()
|
||||
if (self !== top) run()
|
||||
if (self!==top) run()
|
||||
if (self === top) return
|
||||
if (top.location!=self.location&&(top.location.href=self.location.href)) run()
|
||||
if (top.location != self.location) run()
|
||||
if (top.location != location) run()
|
||||
if (self.location != top.location) run()
|
||||
if (parent.frames.length > 0) run()
|
||||
if (window != top) run()
|
||||
if (window.top !== window.self) run()
|
||||
if (window.top!==window.self) run()
|
||||
if (window.self != window.top) run()
|
||||
if (window.top != window.self) run()
|
||||
if (window["top"] != window["parent"]) run()
|
||||
if (window['top'] != window['parent']) run()
|
||||
if (window["top"] != self['parent']) run()
|
||||
if (parent && parent != window) run()
|
||||
if (parent && parent != self) run()
|
||||
if (parent && window != parent) run()
|
||||
if (parent && self != parent) run()
|
||||
if (parent && parent.frames && parent.frames.length > 0) run()
|
||||
if ((self.parent && !(self.parent === self)) && (self.parent.frames.length != 0)) run()
|
||||
if (parent !== null && parent.tag !== 'HostComponent' && parent.tag !== 'HostRoot') { }
|
||||
if (null !== parent && parent.tag !== 'HostComponent' && parent.tag !== 'HostRoot') { }
|
||||
if (top===self) return
|
||||
if (top==self) return
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
14
packages/rewriter/test/fixtures/test.js
vendored
Normal file
14
packages/rewriter/test/fixtures/test.js
vendored
Normal file
@@ -0,0 +1,14 @@
|
||||
// Generated by CoffeeScript 2.2.1
|
||||
(function() {
|
||||
// just an example of transpilation w/ sourcemap -
|
||||
// `test.coffee` is not directly transpiled/executed by any test code
|
||||
// regenerate JS + sourcemap with `coffee -c -m test.coffee`
|
||||
setTimeout(function() {
|
||||
window;
|
||||
var foo;
|
||||
return foo = `${window.top.foo}`;
|
||||
}, 1000);
|
||||
|
||||
}).call(this);
|
||||
|
||||
//# sourceMappingURL=test.js.map
|
||||
13
packages/rewriter/test/fixtures/test.js.map
vendored
Normal file
13
packages/rewriter/test/fixtures/test.js.map
vendored
Normal file
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"version": 3,
|
||||
"file": "test.js",
|
||||
"sourceRoot": "",
|
||||
"sources": [
|
||||
"test.coffee"
|
||||
],
|
||||
"names": [],
|
||||
"mappings": ";AAAA;EAAA;;;EAIA,UAAA,CAAW,QAAA,CAAA,CAAA;IACT;AAAA,QAAA;WACA,GAAA,GAAM,CAAA,CAAA,CAAG,MAAM,CAAC,GAAG,CAAC,GAAd,CAAA;EAFG,CAAX,EAGE,IAHF;AAJA",
|
||||
"sourcesContent": [
|
||||
"# just an example of transpilation w/ sourcemap -\n# `test.coffee` is not directly transpiled/executed by any test code\n# regenerate JS + sourcemap with `coffee -c -m test.coffee`\n\nsetTimeout ->\n window\n foo = \"#{window.top.foo}\"\n, 1000\n"
|
||||
]
|
||||
}
|
||||
4
packages/rewriter/test/mocha.opts
Normal file
4
packages/rewriter/test/mocha.opts
Normal file
@@ -0,0 +1,4 @@
|
||||
test/unit/*
|
||||
-r @packages/ts/register
|
||||
--timeout 10000
|
||||
--recursive
|
||||
150
packages/rewriter/test/unit/deferred-source-map-cache-spec.ts
Normal file
150
packages/rewriter/test/unit/deferred-source-map-cache-spec.ts
Normal file
@@ -0,0 +1,150 @@
|
||||
import { DeferredSourceMapCache } from '../../lib/deferred-source-map-cache'
|
||||
import sinon from 'sinon'
|
||||
import chai, { expect } from 'chai'
|
||||
import chaiAsPromised from 'chai-as-promised'
|
||||
import sinonChai from 'sinon-chai'
|
||||
import {
|
||||
testSourceWithExternalSourceMap,
|
||||
testSourceWithInlineSourceMap,
|
||||
testSourceMap,
|
||||
testSourceWithNoSourceMap,
|
||||
} from '../fixtures'
|
||||
import snapshot from 'snap-shot-it'
|
||||
|
||||
chai.use(chaiAsPromised)
|
||||
chai.use(sinonChai)
|
||||
|
||||
describe('DeferredSourceMapCache', function () {
|
||||
let cache: DeferredSourceMapCache
|
||||
|
||||
beforeEach(() => {
|
||||
cache = new DeferredSourceMapCache(sinon.stub())
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
sinon.restore()
|
||||
})
|
||||
|
||||
context('#defer', () => {
|
||||
it('adds to requests', () => {
|
||||
const request = { uniqueId: 'foo', url: 'bar' }
|
||||
|
||||
cache.defer(request)
|
||||
expect(cache.requests).to.deep.eq([request])
|
||||
})
|
||||
|
||||
it('replaces existing requests for same URL', () => {
|
||||
const request0 = { uniqueId: 'kung-fu', url: 'http://other.url/foo.js' }
|
||||
const request1 = { uniqueId: 'foo', url: 'http://bar.baz/quux.js' }
|
||||
const request2 = { uniqueId: 'kung-foo', url: 'http://bar.baz/quux.js' }
|
||||
|
||||
cache.defer(request0)
|
||||
cache.defer(request1)
|
||||
cache.defer(request2)
|
||||
expect(cache.requests).to.deep.eq([request0, request2])
|
||||
})
|
||||
|
||||
it('throws if uniqueId is duplicated', () => {
|
||||
cache.defer({ uniqueId: 'foo', url: 'bar' })
|
||||
expect(() => {
|
||||
cache.defer({ uniqueId: 'foo', url: 'baz' })
|
||||
}).to.throw
|
||||
})
|
||||
})
|
||||
|
||||
context('#resolve', () => {
|
||||
it('rejects if unknown uniqueId', async () => {
|
||||
cache.defer({
|
||||
uniqueId: 'baz',
|
||||
url: 'quux',
|
||||
})
|
||||
|
||||
await expect(cache.resolve('foo', {})).to.be.rejectedWith('Missing request with ID \'foo\'')
|
||||
})
|
||||
|
||||
it('rejects if request missing JS', async () => {
|
||||
cache.defer({
|
||||
uniqueId: 'foo',
|
||||
url: 'bar',
|
||||
})
|
||||
|
||||
await expect(cache.resolve('foo', {})).to.be.rejectedWith(/^Missing JS/)
|
||||
})
|
||||
|
||||
context('sourcemap generation', () => {
|
||||
it('for JS with no original sourcemap', async () => {
|
||||
cache.defer({
|
||||
uniqueId: 'foo',
|
||||
url: 'bar',
|
||||
js: 'console.log()',
|
||||
resHeaders: {},
|
||||
})
|
||||
|
||||
snapshot(await cache.resolve('foo', {}))
|
||||
})
|
||||
|
||||
it('resolves with cached sourceMap on retry', async () => {
|
||||
cache.defer({
|
||||
uniqueId: 'foo',
|
||||
url: 'bar',
|
||||
js: 'console.log()',
|
||||
resHeaders: {},
|
||||
})
|
||||
|
||||
const result0 = await cache.resolve('foo', {})
|
||||
const result1 = await cache.resolve('foo', {})
|
||||
|
||||
expect(result0).to.eq(result1) // same object reference
|
||||
})
|
||||
|
||||
context('composition', () => {
|
||||
const URL = 'http://somedomain.net/dir/foo.js'
|
||||
|
||||
const testExternalSourceMap = (js, resHeaders, expectRequest = true) => {
|
||||
return async () => {
|
||||
cache.defer({
|
||||
uniqueId: 'foo',
|
||||
url: URL,
|
||||
js,
|
||||
resHeaders,
|
||||
})
|
||||
|
||||
// @ts-ignore: https://github.com/bahmutov/snap-shot-it/issues/522
|
||||
snapshot('composed sourcemap', await cache.resolve('foo', {}), { allowSharedSnapshot: true })
|
||||
|
||||
if (!expectRequest) {
|
||||
return
|
||||
}
|
||||
|
||||
expect(cache.requestLib).to.be.calledWith({
|
||||
url: 'http://somedomain.net/dir/test.js.map',
|
||||
headers: {},
|
||||
timeout: 5000,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
cache.requestLib.resolves({ body: testSourceMap })
|
||||
})
|
||||
|
||||
it('with inlined base64 sourceMappingURL', testExternalSourceMap(testSourceWithInlineSourceMap, {}, false))
|
||||
|
||||
it('with external sourceMappingURL', testExternalSourceMap(testSourceWithExternalSourceMap, {
|
||||
// sourceMappingURL should override headers
|
||||
'SOURCEmap': 'garbage',
|
||||
'x-sourceMAP': 'garbage',
|
||||
}))
|
||||
|
||||
it('with map referenced by sourcemap header', testExternalSourceMap(testSourceWithNoSourceMap, {
|
||||
'SOURCEmap': 'test.js.map',
|
||||
'x-sourceMAP': 'garbage', // SourceMap header should override x-sourcemap
|
||||
}))
|
||||
|
||||
it('with map referenced by x-sourcemap header', testExternalSourceMap(testSourceWithNoSourceMap, {
|
||||
'x-sourceMAP': 'test.js.map',
|
||||
}))
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
56
packages/rewriter/test/unit/html-spec.ts
Normal file
56
packages/rewriter/test/unit/html-spec.ts
Normal file
@@ -0,0 +1,56 @@
|
||||
import { expect } from 'chai'
|
||||
import { rewriteHtmlJs } from '../../lib/html'
|
||||
import snapshot from 'snap-shot-it'
|
||||
import { testHtml } from '../fixtures'
|
||||
|
||||
const URL = 'http://example.com/foo.html'
|
||||
|
||||
const rewriteNoSourceMap = (html) => rewriteHtmlJs(URL, html)
|
||||
|
||||
describe('html rewriter', function () {
|
||||
context('.rewriteHtmlJs', function () {
|
||||
// https://github.com/cypress-io/cypress/issues/2393
|
||||
it('strips SRI', function () {
|
||||
snapshot(rewriteNoSourceMap('<script type="text/javascript" integrity="foo" src="bar">'))
|
||||
|
||||
snapshot(rewriteNoSourceMap('<script type="text/javascript" integrity="foo" src="bar"/>'))
|
||||
|
||||
snapshot(rewriteNoSourceMap('<script type="text/javascript" integrity="foo" src="bar">foo</script>'))
|
||||
|
||||
// should preserve namespaced attrs and still rewrite if no `type`
|
||||
snapshot(rewriteNoSourceMap('<script foo:bar="baz" integrity="foo" src="bar">'))
|
||||
})
|
||||
|
||||
it('rewrites a real-ish document with sourcemaps for inline js', () => {
|
||||
const actual: any = {}
|
||||
|
||||
actual.html = rewriteHtmlJs(URL, testHtml, (sourceInfo) => {
|
||||
actual.sourceInfo = sourceInfo
|
||||
|
||||
return 'foo'
|
||||
})
|
||||
|
||||
snapshot(actual)
|
||||
})
|
||||
|
||||
context('with inline scripts', function () {
|
||||
it('rewrites inline JS with no type', function () {
|
||||
snapshot(rewriteNoSourceMap('<script>window.top</script>'))
|
||||
})
|
||||
|
||||
it('rewrites inline JS with type', function () {
|
||||
snapshot(rewriteNoSourceMap('<script type="text/javascript">window.top</script>'))
|
||||
})
|
||||
|
||||
it('does not rewrite non-JS inline', function () {
|
||||
snapshot(rewriteNoSourceMap('<script type="x-foo/bar">window.top</script>'))
|
||||
})
|
||||
|
||||
it('ignores invalid inline JS', function () {
|
||||
const str = '<script>(((((((((((</script>'
|
||||
|
||||
expect(rewriteNoSourceMap(str)).to.eq(str)
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
342
packages/rewriter/test/unit/js-spec.ts
Normal file
342
packages/rewriter/test/unit/js-spec.ts
Normal file
@@ -0,0 +1,342 @@
|
||||
import _ from 'lodash'
|
||||
import { expect } from 'chai'
|
||||
import { _rewriteJsUnsafe } from '../../lib/js'
|
||||
import fse from 'fs-extra'
|
||||
import Bluebird from 'bluebird'
|
||||
import rp from '@cypress/request-promise'
|
||||
import snapshot from 'snap-shot-it'
|
||||
import * as astTypes from 'ast-types'
|
||||
import sinon from 'sinon'
|
||||
import {
|
||||
testSourceWithExternalSourceMap,
|
||||
testSourceWithInlineSourceMap,
|
||||
} from '../fixtures'
|
||||
|
||||
const URL = 'http://example.com/foo.js'
|
||||
|
||||
function match (varName, prop) {
|
||||
return `globalThis.top.Cypress.resolveWindowReference(globalThis, ${varName}, '${prop}')`
|
||||
}
|
||||
|
||||
function matchLocation () {
|
||||
return `globalThis.top.Cypress.resolveLocationReference(globalThis)`
|
||||
}
|
||||
|
||||
function testExpectedJs (string: string, expected: string) {
|
||||
// use _rewriteJsUnsafe so exceptions can cause the test to fail
|
||||
const actual = _rewriteJsUnsafe(URL, string)
|
||||
|
||||
expect(actual).to.eq(expected)
|
||||
}
|
||||
|
||||
describe('js rewriter', function () {
|
||||
afterEach(() => {
|
||||
sinon.restore()
|
||||
})
|
||||
|
||||
context('.rewriteJs', function () {
|
||||
context('transformations', function () {
|
||||
context('injects Cypress window property resolver', () => {
|
||||
[
|
||||
['window.top', match('window', 'top')],
|
||||
['window.parent', match('window', 'parent')],
|
||||
['window[\'top\']', match('window', 'top')],
|
||||
['window[\'parent\']', match('window', 'parent')],
|
||||
['window["top"]', match('window', 'top')],
|
||||
['window["parent"]', match('window', 'parent')],
|
||||
['foowindow.top', match('foowindow', 'top')],
|
||||
['foowindow[\'top\']', match('foowindow', 'top')],
|
||||
['window.topfoo'],
|
||||
['window[\'topfoo\']'],
|
||||
['window[\'top\'].foo', `${match('window', 'top')}.foo`],
|
||||
['window.top.foo', `${match('window', 'top')}.foo`],
|
||||
['window.top["foo"]', `${match('window', 'top')}["foo"]`],
|
||||
['window[\'top\']["foo"]', `${match('window', 'top')}["foo"]`],
|
||||
[
|
||||
'if (window["top"] != window["parent"]) run()',
|
||||
`if (${match('window', 'top')} != ${match('window', 'parent')}) run()`,
|
||||
],
|
||||
[
|
||||
'if (top != self) run()',
|
||||
`if (${match('globalThis', 'top')} != self) run()`,
|
||||
],
|
||||
[
|
||||
'if (window != top) run()',
|
||||
`if (window != ${match('globalThis', 'top')}) run()`,
|
||||
],
|
||||
[
|
||||
'if (top.location != self.location) run()',
|
||||
`if (${match('top', 'location')} != ${match('self', 'location')}) run()`,
|
||||
],
|
||||
[
|
||||
'n = (c = n).parent',
|
||||
`n = ${match('c = n', 'parent')}`,
|
||||
],
|
||||
[
|
||||
'e.top = "0"',
|
||||
`globalThis.top.Cypress.resolveWindowReference(globalThis, e, 'top', "0")`,
|
||||
],
|
||||
['e.top += 0'],
|
||||
[
|
||||
'e.bottom += e.top',
|
||||
`e.bottom += ${match('e', 'top')}`,
|
||||
],
|
||||
[
|
||||
'if (a = (e.top = "0")) { }',
|
||||
`if (a = (globalThis.top.Cypress.resolveWindowReference(globalThis, e, 'top', "0"))) { }`,
|
||||
],
|
||||
// test that double quotes remain double-quoted
|
||||
[
|
||||
'a = "b"; window.top',
|
||||
`a = "b"; ${match('window', 'top')}`,
|
||||
],
|
||||
['({ top: "foo", parent: "bar" })'],
|
||||
['top: "foo"; parent: "bar";'],
|
||||
['top: break top'],
|
||||
['top: continue top;'],
|
||||
[
|
||||
'function top() { window.top }; function parent(...top) { window.top }',
|
||||
`function top() { ${match('window', 'top')} }; function parent(...top) { ${match('window', 'top')} }`,
|
||||
],
|
||||
[
|
||||
'(top, ...parent) => { window.top }',
|
||||
`(top, ...parent) => { ${match('window', 'top')} }`,
|
||||
],
|
||||
[
|
||||
'(function top() { window.top }); (function parent(...top) { window.top })',
|
||||
`(function top() { ${match('window', 'top')} }); (function parent(...top) { ${match('window', 'top')} })`,
|
||||
],
|
||||
[
|
||||
'top += 4',
|
||||
],
|
||||
[
|
||||
// test that arguments are not replaced
|
||||
'function foo(location) { location.href = \'bar\' }',
|
||||
],
|
||||
[
|
||||
// test that global variables are replaced
|
||||
'function foo(notLocation) { location.href = \'bar\' }',
|
||||
`function foo(notLocation) { ${matchLocation()}.href = \'bar\' }`,
|
||||
],
|
||||
[
|
||||
// test that scoped declarations are not replaced
|
||||
'let location = "foo"; location.href = \'bar\'',
|
||||
],
|
||||
[
|
||||
'location.href = "bar"',
|
||||
`${matchLocation()}.href = "bar"`,
|
||||
],
|
||||
[
|
||||
'location = "bar"',
|
||||
`${matchLocation()}.href = "bar"`,
|
||||
],
|
||||
[
|
||||
'window.location.href = "bar"',
|
||||
`${match('window', 'location')}.href = "bar"`,
|
||||
],
|
||||
[
|
||||
'window.location = "bar"',
|
||||
`globalThis.top.Cypress.resolveWindowReference(globalThis, window, 'location', "bar")`,
|
||||
],
|
||||
]
|
||||
.forEach(([string, expected]) => {
|
||||
if (!expected) {
|
||||
expected = string
|
||||
}
|
||||
|
||||
it(`${string} => ${expected}`, () => {
|
||||
testExpectedJs(string, expected)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
it('throws an error via the driver if AST visiting throws an error', () => {
|
||||
// if astTypes.visit throws, that indicates a bug in our js-rules, and so we should stop rewriting
|
||||
const err = new Error('foo')
|
||||
|
||||
err.stack = 'stack'
|
||||
|
||||
sinon.stub(astTypes, 'visit').throws(err)
|
||||
|
||||
const actual = _rewriteJsUnsafe(URL, 'console.log()')
|
||||
|
||||
snapshot(actual)
|
||||
})
|
||||
|
||||
it('replaces jira window getter', () => {
|
||||
const jira = `\
|
||||
for (; !function (n) {
|
||||
return n === n.parent
|
||||
}(n);) {}\
|
||||
`
|
||||
|
||||
const jira2 = `\
|
||||
(function(n){for(;!function(l){return l===l.parent}(l)&&function(l){try{if(void 0==l.location.href)return!1}catch(l){return!1}return!0}(l.parent);)l=l.parent;return l})\
|
||||
`
|
||||
|
||||
const jira3 = `\
|
||||
function satisfiesSameOrigin(w) {
|
||||
try {
|
||||
// Accessing location.href from a window on another origin will throw an exception.
|
||||
if ( w.location.href == undefined) {
|
||||
return false;
|
||||
}
|
||||
} catch (e) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
function isTopMostWindow(w) {
|
||||
return w === w.parent;
|
||||
}
|
||||
|
||||
while (!isTopMostWindow(parentOf) && satisfiesSameOrigin(parentOf.parent)) {
|
||||
parentOf = parentOf.parent;
|
||||
}\
|
||||
`
|
||||
|
||||
testExpectedJs(jira, `\
|
||||
for (; !function (n) {
|
||||
return n === ${match('n', 'parent')};
|
||||
}(n);) {}\
|
||||
`)
|
||||
|
||||
testExpectedJs(jira2, `\
|
||||
(function(n){for(;!function(l){return l===${match('l', 'parent')};}(l)&&function(l){try{if(void 0==${match('l', 'location')}.href)return!1}catch(l){return!1}return!0}(${match('l', 'parent')});)l=${match('l', 'parent')};return l})\
|
||||
`)
|
||||
|
||||
testExpectedJs(jira3, `\
|
||||
function satisfiesSameOrigin(w) {
|
||||
try {
|
||||
// Accessing location.href from a window on another origin will throw an exception.
|
||||
if ( ${match('w', 'location')}.href == undefined) {
|
||||
return false;
|
||||
}
|
||||
} catch (e) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
function isTopMostWindow(w) {
|
||||
return w === ${match('w', 'parent')};
|
||||
}
|
||||
|
||||
while (!isTopMostWindow(parentOf) && satisfiesSameOrigin(${match('parentOf', 'parent')})) {
|
||||
parentOf = ${match('parentOf', 'parent')};
|
||||
}\
|
||||
`)
|
||||
})
|
||||
|
||||
describe('libs', () => {
|
||||
const cdnUrl = 'https://cdnjs.cloudflare.com/ajax/libs'
|
||||
|
||||
const needsDash = ['backbone', 'underscore']
|
||||
|
||||
let libs = {
|
||||
jquery: `${cdnUrl}/jquery/3.3.1/jquery.js`,
|
||||
jqueryui: `${cdnUrl}/jqueryui/1.12.1/jquery-ui.js`,
|
||||
angular: `${cdnUrl}/angular.js/1.6.5/angular.js`,
|
||||
bootstrap: `${cdnUrl}/twitter-bootstrap/4.0.0/js/bootstrap.js`,
|
||||
moment: `${cdnUrl}/moment.js/2.20.1/moment.js`,
|
||||
lodash: `${cdnUrl}/lodash.js/4.17.5/lodash.js`,
|
||||
vue: `${cdnUrl}/vue/2.5.13/vue.js`,
|
||||
backbone: `${cdnUrl}/backbone.js/1.3.3/backbone.js`,
|
||||
cycle: `${cdnUrl}/cyclejs-core/7.0.0/cycle.js`,
|
||||
d3: `${cdnUrl}/d3/4.13.0/d3.js`,
|
||||
underscore: `${cdnUrl}/underscore.js/1.8.3/underscore.js`,
|
||||
foundation: `${cdnUrl}/foundation/6.4.3/js/foundation.js`,
|
||||
require: `${cdnUrl}/require.js/2.3.5/require.js`,
|
||||
rxjs: `${cdnUrl}/rxjs/5.5.6/Rx.js`,
|
||||
bluebird: `${cdnUrl}/bluebird/3.5.1/bluebird.js`,
|
||||
}
|
||||
|
||||
libs = _
|
||||
.chain(libs)
|
||||
.clone()
|
||||
.reduce((memo, url, lib) => {
|
||||
memo[lib] = url
|
||||
memo[`${lib}Min`] = url
|
||||
.replace(/js$/, 'min.js')
|
||||
.replace(/css$/, 'min.css')
|
||||
|
||||
if (needsDash.includes(lib)) {
|
||||
memo[`${lib}Min`] = url.replace('min', '-min')
|
||||
}
|
||||
|
||||
return memo
|
||||
}
|
||||
, {})
|
||||
.extend({
|
||||
knockoutDebug: `${cdnUrl}/knockout/3.4.2/knockout-debug.js`,
|
||||
knockoutMin: `${cdnUrl}/knockout/3.4.2/knockout-min.js`,
|
||||
emberMin: `${cdnUrl}/ember.js/2.18.2/ember.min.js`,
|
||||
emberProd: `${cdnUrl}/ember.js/2.18.2/ember.prod.js`,
|
||||
reactDev: `${cdnUrl}/react/16.2.0/umd/react.development.js`,
|
||||
reactProd: `${cdnUrl}/react/16.2.0/umd/react.production.min.js`,
|
||||
vendorBundle: 'https://s3.amazonaws.com/internal-test-runner-assets.cypress.io/vendor.bundle.js',
|
||||
hugeApp: 'https://s3.amazonaws.com/internal-test-runner-assets.cypress.io/huge_app.js',
|
||||
})
|
||||
.value() as unknown as typeof libs
|
||||
|
||||
_.each(libs, (url, lib) => {
|
||||
it(`does not corrupt code from '${lib}'`, function () {
|
||||
// may have to download and rewrite large files
|
||||
this.timeout(20000)
|
||||
|
||||
const pathToLib = `/tmp/${lib}`
|
||||
|
||||
const downloadFile = () => {
|
||||
return rp(url)
|
||||
.then((resp) => {
|
||||
return Bluebird.fromCallback((cb) => {
|
||||
fse.writeFile(pathToLib, resp, cb)
|
||||
})
|
||||
.return(resp)
|
||||
})
|
||||
}
|
||||
|
||||
return fse
|
||||
.readFile(pathToLib, 'utf8')
|
||||
.catch(downloadFile)
|
||||
.then((libCode) => {
|
||||
const stripped = _rewriteJsUnsafe(url, libCode)
|
||||
|
||||
expect(() => eval(stripped), 'is valid JS').to.not.throw
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
context('source maps', function () {
|
||||
it('emits sourceInfo as expected', function (done) {
|
||||
_rewriteJsUnsafe(URL, 'window.top', (sourceInfo) => {
|
||||
snapshot(sourceInfo)
|
||||
done()
|
||||
|
||||
return ''
|
||||
})
|
||||
})
|
||||
|
||||
it('emits info about existing inline sourcemap', function (done) {
|
||||
_rewriteJsUnsafe(URL, testSourceWithInlineSourceMap, (sourceInfo) => {
|
||||
snapshot(sourceInfo)
|
||||
done()
|
||||
|
||||
return ''
|
||||
})
|
||||
})
|
||||
|
||||
it('emits info about existing external sourcemap', function (done) {
|
||||
_rewriteJsUnsafe(URL, testSourceWithExternalSourceMap, (sourceInfo) => {
|
||||
snapshot(sourceInfo)
|
||||
done()
|
||||
|
||||
return ''
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
11
packages/rewriter/tsconfig.json
Normal file
11
packages/rewriter/tsconfig.json
Normal file
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"extends": "./../ts/tsconfig.json",
|
||||
"include": [
|
||||
"*.ts",
|
||||
"lib/*.ts",
|
||||
"lib/**/*.ts"
|
||||
],
|
||||
"files": [
|
||||
"./../ts/index.d.ts"
|
||||
]
|
||||
}
|
||||
@@ -748,3 +748,86 @@ exports['e2e visit / low response timeout / calls onBeforeLoad when overwriting
|
||||
|
||||
|
||||
`
|
||||
|
||||
exports['e2e visit / low response timeout / passes with experimentalSourceRewriting'] = `
|
||||
|
||||
====================================================================================================
|
||||
|
||||
(Run Starting)
|
||||
|
||||
┌────────────────────────────────────────────────────────────────────────────────────────────────┐
|
||||
│ Cypress: 1.2.3 │
|
||||
│ Browser: FooBrowser 88 │
|
||||
│ Specs: 1 found (source_rewriting_spec.js) │
|
||||
│ Searched: cypress/integration/source_rewriting_spec.js │
|
||||
│ Experiments: experimentalSourceRewriting=true │
|
||||
└────────────────────────────────────────────────────────────────────────────────────────────────┘
|
||||
|
||||
|
||||
────────────────────────────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
Running: source_rewriting_spec.js (1 of 1)
|
||||
|
||||
|
||||
source rewriting spec
|
||||
✓ obstructive code is replaced
|
||||
issue 3975
|
||||
✓ can relative redirect in a xhr onload
|
||||
✓ can relative redirect in a onclick handler
|
||||
✓ can relative redirect in a settimeout with a base tag
|
||||
- Login demo
|
||||
it can relative redirect in a settimeout
|
||||
✓ with location.href
|
||||
✓ with window.location.href
|
||||
✓ with location.replace()
|
||||
✓ with location.assign()
|
||||
✓ with location = ...
|
||||
✓ with window.location = ...
|
||||
✓ with location.search
|
||||
✓ with location.pathname
|
||||
can load some well-known sites in a timely manner
|
||||
- http://google.com
|
||||
- http://facebook.com
|
||||
- http://cypress.io
|
||||
- http://docs.cypress.io
|
||||
- http://github.com
|
||||
|
||||
|
||||
12 passing
|
||||
6 pending
|
||||
|
||||
|
||||
(Results)
|
||||
|
||||
┌────────────────────────────────────────────────────────────────────────────────────────────────┐
|
||||
│ Tests: 18 │
|
||||
│ Passing: 12 │
|
||||
│ Failing: 0 │
|
||||
│ Pending: 6 │
|
||||
│ Skipped: 0 │
|
||||
│ Screenshots: 0 │
|
||||
│ Video: true │
|
||||
│ Duration: X seconds │
|
||||
│ Spec Ran: source_rewriting_spec.js │
|
||||
└────────────────────────────────────────────────────────────────────────────────────────────────┘
|
||||
|
||||
|
||||
(Video)
|
||||
|
||||
- Started processing: Compressing to 32 CRF
|
||||
- Finished processing: /XXX/XXX/XXX/cypress/videos/source_rewriting_spec.js.mp4 (X second)
|
||||
|
||||
|
||||
====================================================================================================
|
||||
|
||||
(Run Finished)
|
||||
|
||||
|
||||
Spec Tests Passing Failing Pending Skipped
|
||||
┌────────────────────────────────────────────────────────────────────────────────────────────────┐
|
||||
│ ✔ source_rewriting_spec.js XX:XX 18 12 - 6 - │
|
||||
└────────────────────────────────────────────────────────────────────────────────────────────────┘
|
||||
✔ All specs passed! XX:XX 18 12 - 6 -
|
||||
|
||||
|
||||
`
|
||||
|
||||
@@ -100,7 +100,7 @@ systemConfigKeys = toWords """
|
||||
# Know experimental flags / values
|
||||
# each should start with "experimental" and be camel cased
|
||||
# example: experimentalComponentTesting
|
||||
experimentalConfigKeys = ['experimentalGetCookiesSameSite', "experimentalComponentTesting"]
|
||||
experimentalConfigKeys = ['experimentalGetCookiesSameSite', 'experimentalSourceRewriting', 'experimentalComponentTesting']
|
||||
|
||||
CONFIG_DEFAULTS = {
|
||||
port: null
|
||||
@@ -165,6 +165,7 @@ CONFIG_DEFAULTS = {
|
||||
# TODO: example for component testing with subkeys
|
||||
# experimentalComponentTesting: { componentFolder: 'cypress/component' }
|
||||
experimentalGetCookiesSameSite: false
|
||||
experimentalSourceRewriting: false
|
||||
}
|
||||
|
||||
validationRules = {
|
||||
@@ -210,6 +211,7 @@ validationRules = {
|
||||
componentFolder: v.isStringOrFalse
|
||||
# experimental flag validation below
|
||||
experimentalGetCookiesSameSite: v.isBoolean
|
||||
experimentalSourceRewriting: v.isBoolean
|
||||
}
|
||||
|
||||
convertRelativeToAbsolutePaths = (projectRoot, obj, defaults = {}) ->
|
||||
|
||||
@@ -52,6 +52,7 @@ interface StringValues {
|
||||
*/
|
||||
const _summaries: StringValues = {
|
||||
experimentalComponentTesting: 'Framework-specific component testing, uses `componentFolder` to load component specs',
|
||||
experimentalSourceRewriting: 'Enables AST-based JS/HTML rewriting. This may fix issues caused by the existing regex-based JS/HTML replacement algorithm.',
|
||||
experimentalGetCookiesSameSite: 'Adds `sameSite` values to the objects yielded from `cy.setCookie()`, `cy.getCookie()`, and `cy.getCookies()`. This will become the default behavior in Cypress 5.0.',
|
||||
}
|
||||
|
||||
@@ -67,6 +68,7 @@ const _summaries: StringValues = {
|
||||
*/
|
||||
const _names: StringValues = {
|
||||
experimentalComponentTesting: 'Component Testing',
|
||||
experimentalSourceRewriting: 'Improved source rewriting',
|
||||
experimentalGetCookiesSameSite: 'Set `sameSite` property when retrieving cookies',
|
||||
}
|
||||
|
||||
|
||||
@@ -741,12 +741,6 @@ module.exports = function (options = {}) {
|
||||
return this.create(options, true)
|
||||
.then(this.normalizeResponse.bind(this, push))
|
||||
.then((resp) => {
|
||||
// TODO: move duration somewhere...?
|
||||
// does node store this somewhere?
|
||||
// we could probably calculate this ourselves
|
||||
// by using the date headers
|
||||
let loc
|
||||
|
||||
resp.duration = Date.now() - ms
|
||||
resp.allRequestResponses = requestResponses
|
||||
|
||||
@@ -754,10 +748,10 @@ module.exports = function (options = {}) {
|
||||
resp.redirects = redirects
|
||||
}
|
||||
|
||||
if ((options.followRedirect === false) && (loc = resp.headers.location)) {
|
||||
if ((options.followRedirect === false) && resp.headers.location) {
|
||||
// resolve the new location head against
|
||||
// the current url
|
||||
resp.redirectedToUrl = url.resolve(options.url, loc)
|
||||
resp.redirectedToUrl = url.resolve(options.url, resp.headers.location)
|
||||
}
|
||||
|
||||
return this.setCookiesOnBrowser(resp, currentUrl, automationFn)
|
||||
|
||||
@@ -53,6 +53,10 @@ module.exports = ({ app, config, getRemoteState, networkProxy, project, onError
|
||||
xhrs.handle(req, res, config, next)
|
||||
})
|
||||
|
||||
app.get('/__cypress/source-maps/:id.map', (req, res) => {
|
||||
networkProxy.handleSourceMapRequest(req, res)
|
||||
})
|
||||
|
||||
// special fallback - serve local files from the project's root folder
|
||||
app.get('/__root/*', (req, res) => {
|
||||
const file = path.join(config.projectRoot, req.params[0])
|
||||
|
||||
@@ -20,6 +20,7 @@ debug = require("debug")("cypress:server:server")
|
||||
uri
|
||||
} = require("@packages/network")
|
||||
{ NetworkProxy } = require("@packages/proxy")
|
||||
{ createInitialWorkers } = require("@packages/rewriter")
|
||||
origin = require("./util/origin")
|
||||
ensureUrl = require("./util/ensure-url")
|
||||
appData = require("./util/app_data")
|
||||
@@ -183,6 +184,9 @@ class Server
|
||||
|
||||
@_networkProxy = new NetworkProxy({ config, getRemoteState, getFileServerToken, request: @_request })
|
||||
|
||||
if config.experimentalSourceRewriting
|
||||
createInitialWorkers()
|
||||
|
||||
@createHosts(config.hosts)
|
||||
|
||||
@createRoutes({
|
||||
|
||||
@@ -99,15 +99,6 @@ describe "e2e visit", ->
|
||||
}
|
||||
})
|
||||
|
||||
## this tests that hashes are applied during a visit
|
||||
## which forces the browser to scroll to the div
|
||||
## additionally this tests that jquery.js is not truncated
|
||||
## due to __cypress.initial cookies not being cleared by
|
||||
## the hash.html response
|
||||
|
||||
## additionally this tests that xhr request headers + body
|
||||
## can reach the backend without being modified or changed
|
||||
## by the cypress proxy in any way
|
||||
e2e.it "passes", {
|
||||
spec: "visit_spec.coffee"
|
||||
snapshot: true
|
||||
@@ -119,6 +110,20 @@ describe "e2e visit", ->
|
||||
serv.destroy()
|
||||
}
|
||||
|
||||
e2e.it "passes with experimentalSourceRewriting", {
|
||||
spec: "source_rewriting_spec.js"
|
||||
config: {
|
||||
experimentalSourceRewriting: true
|
||||
}
|
||||
snapshot: true
|
||||
onRun: (exec) ->
|
||||
startTlsV1Server(6776)
|
||||
.then (serv) ->
|
||||
exec()
|
||||
.then ->
|
||||
serv.destroy()
|
||||
}
|
||||
|
||||
e2e.it "fails when network connection immediately fails", {
|
||||
spec: "visit_http_network_error_failing_spec.coffee"
|
||||
snapshot: true
|
||||
|
||||
@@ -1101,7 +1101,7 @@ describe "Routes", ->
|
||||
.get("/gzip")
|
||||
.matchHeader("accept-encoding", "gzip")
|
||||
.replyWithFile(200, Fixtures.path("server/gzip.html.gz"), {
|
||||
"Content-Type": "application/javascript"
|
||||
"Content-Type": "text/html"
|
||||
"Content-Encoding": "gzip"
|
||||
})
|
||||
|
||||
@@ -1132,6 +1132,7 @@ describe "Routes", ->
|
||||
js += chunk
|
||||
res.write(chunk)
|
||||
|
||||
## note - this is unintentionally invalid JS, just try executing it anywhere
|
||||
write("function ")
|
||||
_.times 100, =>
|
||||
write("😡😈".repeat(10))
|
||||
|
||||
@@ -0,0 +1,101 @@
|
||||
describe('source rewriting spec', function () {
|
||||
it('obstructive code is replaced', function () {
|
||||
// based off of driver e2e security_spec
|
||||
cy.visit('/obstructive_code.html')
|
||||
cy.contains('html ran')
|
||||
cy.contains('js ran')
|
||||
cy.get('body').then(([body]) => {
|
||||
expect(body.innerText).to.not.contain('security triggered')
|
||||
})
|
||||
})
|
||||
|
||||
// @see https://github.com/cypress-io/cypress/issues/3975
|
||||
context('issue 3975', function () {
|
||||
it('can relative redirect in a xhr onload', function () {
|
||||
cy.visit('/static/xhr_onload_redirect.html')
|
||||
cy.location('pathname').should('eq', '/static/index.html')
|
||||
})
|
||||
|
||||
context('it can relative redirect in a settimeout', function () {
|
||||
it('with location.href', function () {
|
||||
cy.visit('/static/settimeout_redirect_href.html')
|
||||
cy.location('pathname').should('eq', '/static/index.html')
|
||||
})
|
||||
|
||||
it('with window.location.href', function () {
|
||||
cy.visit('/static/settimeout_redirect_window_href.html')
|
||||
cy.location('pathname').should('eq', '/static/index.html')
|
||||
})
|
||||
|
||||
it('with location.replace()', function () {
|
||||
cy.visit('/static/settimeout_redirect_replace.html')
|
||||
cy.location('pathname').should('eq', '/static/index.html')
|
||||
})
|
||||
|
||||
it('with location.assign()', function () {
|
||||
cy.visit('/static/settimeout_redirect_assign.html')
|
||||
cy.location('pathname').should('eq', '/static/index.html')
|
||||
})
|
||||
|
||||
it('with location = ...', function () {
|
||||
cy.visit('/static/settimeout_redirect_set_location.html')
|
||||
cy.location('pathname').should('eq', '/static/index.html')
|
||||
})
|
||||
|
||||
it('with window.location = ...', function () {
|
||||
cy.visit('/static/settimeout_redirect_set_window_location.html')
|
||||
cy.location('pathname').should('eq', '/static/index.html')
|
||||
})
|
||||
|
||||
it('with location.search', function () {
|
||||
cy.visit('/static/settimeout_redirect_search.html')
|
||||
cy.location().should('include', {
|
||||
pathname: '/static/settimeout_redirect_search.html',
|
||||
search: '?foo',
|
||||
})
|
||||
})
|
||||
|
||||
it('with location.pathname', function () {
|
||||
cy.visit('/static/settimeout_redirect_pathname.html')
|
||||
cy.location('pathname').should('eq', '/index.html')
|
||||
})
|
||||
})
|
||||
|
||||
it('can relative redirect in a onclick handler', function () {
|
||||
cy.visit('/static/onclick_redirect.html')
|
||||
cy.get('button').click()
|
||||
cy.location('pathname').should('eq', '/static/index.html')
|
||||
})
|
||||
|
||||
it('can relative redirect in a settimeout with a base tag', function () {
|
||||
cy.visit('/static/settimeout_basetag_redirect.html')
|
||||
cy.location('pathname').should('eq', '/static/foo/bar/index.html')
|
||||
})
|
||||
|
||||
// NOTE: user's repro
|
||||
it.skip('Login demo', function () {
|
||||
// cy.on('fail', console.error)
|
||||
cy.visit('https://apex.oracle.com/pls/apex/f?p=54707:LOGIN_DESKTOP', { timeout: 60000 })
|
||||
cy.get('#P9999_USERNAME').type('ApexUser')
|
||||
cy.get('#P9999_PASSWORD').type('Oradoc_db1')
|
||||
cy.get('.t-Button').click()
|
||||
})
|
||||
})
|
||||
|
||||
// NOTE: skip in CI for now - can be flaky
|
||||
context.skip('can load some well-known sites in a timely manner', () => {
|
||||
[
|
||||
// FIXME: has to be HTTPS - https://github.com/cypress-io/cypress/issues/7268
|
||||
// 'http://apple.com',
|
||||
'http://google.com',
|
||||
'http://facebook.com',
|
||||
'http://cypress.io',
|
||||
'http://docs.cypress.io',
|
||||
'http://github.com',
|
||||
].forEach((url) => {
|
||||
it(url, () => {
|
||||
cy.visit(url, { timeout: 60000 })
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,70 @@
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<body>
|
||||
testing security clickjacking and framebusting
|
||||
|
||||
<script type="text/javascript" src="/static/obstructive_code.js"></script>
|
||||
<script type="text/javascript">
|
||||
(function () {
|
||||
function run () {
|
||||
const div = document.createElement('div')
|
||||
|
||||
div.innerText = `security triggered ${(new Error).stack.split('\n', 3)[2]}`
|
||||
document.body.appendChild(div)
|
||||
}
|
||||
|
||||
window.topFoo = "foo"
|
||||
window.parentFoo = "foo"
|
||||
|
||||
;(function() {
|
||||
const top = 'foo'
|
||||
const parent = 'foo'
|
||||
const self = 'foo'
|
||||
|
||||
// should stay local
|
||||
if (top !== self) run()
|
||||
if (parent !== self) run()
|
||||
if (self !== top) run()
|
||||
if (self !== parent) run()
|
||||
})()
|
||||
|
||||
// TODO: replace object pattern destructuring
|
||||
// ;(function() {
|
||||
// const { top, parent, location } = window
|
||||
|
||||
// if (location != top.location) run()
|
||||
// if (parent != top.parent) run()
|
||||
// if (top != globalThis.top) run()
|
||||
// })()
|
||||
|
||||
|
||||
if (top != self) run()
|
||||
if (top!=self) run()
|
||||
if (top.location != self.location) run()
|
||||
if (top.location != location) run()
|
||||
if (parent.frames.length > 0) run()
|
||||
if (window != top) run()
|
||||
if (window.top !== window.self) run()
|
||||
if (window.top!==window.self) run()
|
||||
if (window.self != window.top) run()
|
||||
if (window.top != window.self) run()
|
||||
if (window["top"] != window["parent"]) run()
|
||||
if (window['top'] != window['parent']) run()
|
||||
if (window["top"] != self['parent']) run()
|
||||
if (parent && parent != window) run()
|
||||
if (parent && parent != self) run()
|
||||
if (parent && window.topFoo != topFoo) run()
|
||||
if (parent && window.parentFoo != parentFoo) run()
|
||||
if (parent && window != parent) run()
|
||||
if (parent && self != parent) run()
|
||||
if (parent && parent.frames && parent.frames.length > 0) run()
|
||||
if ((self.parent && !(self.parent === self)) && (self.parent.frames.length != 0)) run()
|
||||
|
||||
const div = document.createElement('div')
|
||||
|
||||
div.innerText = 'html ran'
|
||||
document.body.appendChild(div)
|
||||
})()
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,61 @@
|
||||
/* eslint-disable */
|
||||
|
||||
(function () {
|
||||
function run () {
|
||||
const div = document.createElement('div')
|
||||
|
||||
div.innerText = `security triggered ${(new Error).stack.split('\n', 3)[2]}`
|
||||
document.body.appendChild(div)
|
||||
}
|
||||
|
||||
window.topFoo = "foo"
|
||||
window.parentFoo = "foo"
|
||||
|
||||
;(function() {
|
||||
const top = 'foo'
|
||||
const parent = 'foo'
|
||||
const self = 'foo'
|
||||
|
||||
// should stay local
|
||||
if (top !== self) run()
|
||||
if (parent !== self) run()
|
||||
if (self !== top) run()
|
||||
if (self !== parent) run()
|
||||
})()
|
||||
|
||||
// TODO: replace object pattern destructuring
|
||||
// ;(function() {
|
||||
// const { top, parent, location } = window
|
||||
|
||||
// if (location != top.location) run()
|
||||
// if (parent != top.parent) run()
|
||||
// if (top != globalThis.top) run()
|
||||
// })()
|
||||
|
||||
if (top != self) run()
|
||||
if (top!=self) run()
|
||||
if (top.location != self.location) run()
|
||||
if (top.location != location) run()
|
||||
if (parent.frames.length > 0) run()
|
||||
if (window != top) run()
|
||||
if (window.top !== window.self) run()
|
||||
if (window.top!==window.self) run()
|
||||
if (window.self != window.top) run()
|
||||
if (window.top != window.self) run()
|
||||
if (window["top"] != window["parent"]) run()
|
||||
if (window['top'] != window['parent']) run()
|
||||
if (window["top"] != self['parent']) run()
|
||||
if (parent && parent != window) run()
|
||||
if (parent && parent != self) run()
|
||||
if (parent && window.topFoo != topFoo) run()
|
||||
if (parent && window.parentFoo != parentFoo) run()
|
||||
if (parent && window != parent) run()
|
||||
if (parent && self != parent) run()
|
||||
if (parent && parent.frames && parent.frames.length > 0) run()
|
||||
if ((self.parent && !(self.parent === self)) && (self.parent.frames.length != 0)) run()
|
||||
|
||||
const div = document.createElement('div')
|
||||
|
||||
div.innerText = 'js ran'
|
||||
document.body.appendChild(div)
|
||||
})()
|
||||
@@ -0,0 +1,14 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<title>Document</title>
|
||||
</head>
|
||||
<body>
|
||||
<button>click me</button>
|
||||
<script type="text/javascript">
|
||||
document.querySelector('button').onclick = () => {
|
||||
location.href = 'index.html'
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,15 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<base href="/static/foo/bar/">
|
||||
<title>Document</title>
|
||||
</head>
|
||||
<body>
|
||||
<script type="text/javascript">
|
||||
setTimeout(() => {
|
||||
debugger
|
||||
location.href = 'index.html';
|
||||
}, 1)
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,13 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<title>Document</title>
|
||||
</head>
|
||||
<body>
|
||||
<script type="text/javascript">
|
||||
setTimeout(() => {
|
||||
location.assign('index.html');
|
||||
}, 1)
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,13 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<title>Document</title>
|
||||
</head>
|
||||
<body>
|
||||
<script type="text/javascript">
|
||||
setTimeout(() => {
|
||||
location.href = 'index.html';
|
||||
}, 1)
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,13 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<title>Document</title>
|
||||
</head>
|
||||
<body>
|
||||
<script type="text/javascript">
|
||||
setTimeout(() => {
|
||||
location.pathname = 'index.html';
|
||||
}, 1)
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,13 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<title>Document</title>
|
||||
</head>
|
||||
<body>
|
||||
<script type="text/javascript">
|
||||
setTimeout(() => {
|
||||
location.replace('index.html');
|
||||
}, 1)
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,15 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<title>Document</title>
|
||||
</head>
|
||||
<body>
|
||||
<script type="text/javascript">
|
||||
setTimeout(() => {
|
||||
if (!location.search) {
|
||||
location.search = '?foo';
|
||||
}
|
||||
}, 1)
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,13 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<title>Document</title>
|
||||
</head>
|
||||
<body>
|
||||
<script type="text/javascript">
|
||||
setTimeout(() => {
|
||||
location = 'index.html';
|
||||
}, 1)
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,13 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<title>Document</title>
|
||||
</head>
|
||||
<body>
|
||||
<script type="text/javascript">
|
||||
setTimeout(() => {
|
||||
window.location = 'index.html';
|
||||
}, 1)
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,13 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<title>Document</title>
|
||||
</head>
|
||||
<body>
|
||||
<script type="text/javascript">
|
||||
setTimeout(() => {
|
||||
window.location.href = 'index.html';
|
||||
}, 1)
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,18 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<title>Document</title>
|
||||
</head>
|
||||
<body>
|
||||
<button>click me</button>
|
||||
<script type="text/javascript">
|
||||
const x = new XMLHttpRequest()
|
||||
x.open('GET', '/cypress.json')
|
||||
x.onload = () => {
|
||||
location.href = 'index.html';
|
||||
console.log(location);
|
||||
}
|
||||
x.send()
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -798,6 +798,7 @@ describe "lib/config", ->
|
||||
responseTimeout: { value: 30000, from: "default" },
|
||||
execTimeout: { value: 60000, from: "default" },
|
||||
experimentalGetCookiesSameSite: { value: false, from: "default" },
|
||||
experimentalSourceRewriting: { value: false, from: "default" },
|
||||
taskTimeout: { value: 60000, from: "default" },
|
||||
numTestsKeptInMemory: { value: 50, from: "default" },
|
||||
waitForAnimations: { value: true, from: "default" },
|
||||
@@ -870,6 +871,7 @@ describe "lib/config", ->
|
||||
responseTimeout: { value: 30000, from: "default" },
|
||||
execTimeout: { value: 60000, from: "default" },
|
||||
experimentalGetCookiesSameSite: { value: false, from: "default" },
|
||||
experimentalSourceRewriting: { value: false, from: "default" },
|
||||
taskTimeout: { value: 60000, from: "default" },
|
||||
numTestsKeptInMemory: { value: 50, from: "default" },
|
||||
waitForAnimations: { value: true, from: "default" },
|
||||
|
||||
155
yarn.lock
155
yarn.lock
@@ -3581,13 +3581,20 @@
|
||||
resolved "https://registry.yarnpkg.com/@sindresorhus/is/-/is-0.7.0.tgz#9a06f4f137ee84d7df0460c1fdb1135ffa6c50fd"
|
||||
integrity sha512-ONhaKPIufzzrlNbqtWFFd+jlnemX6lJAgq9ZeiZtS7I1PIf/la7CW4m83rTXRnVnsMbW2k56pGYu7AUFJD9Pow==
|
||||
|
||||
"@sinonjs/commons@^1", "@sinonjs/commons@^1.2.0", "@sinonjs/commons@^1.3.0", "@sinonjs/commons@^1.4.0", "@sinonjs/commons@^1.6.0", "@sinonjs/commons@^1.7.0":
|
||||
"@sinonjs/commons@^1", "@sinonjs/commons@^1.2.0", "@sinonjs/commons@^1.3.0", "@sinonjs/commons@^1.4.0", "@sinonjs/commons@^1.6.0", "@sinonjs/commons@^1.7.0", "@sinonjs/commons@^1.7.2":
|
||||
version "1.7.2"
|
||||
resolved "https://registry.yarnpkg.com/@sinonjs/commons/-/commons-1.7.2.tgz#505f55c74e0272b43f6c52d81946bed7058fc0e2"
|
||||
integrity sha512-+DUO6pnp3udV/v2VfUWgaY5BIE1IfT7lLfeDzPVeMT1XKkaAp9LgSI9x5RtrFQoZ9Oi0PgXQQHPaoKu7dCjVxw==
|
||||
dependencies:
|
||||
type-detect "4.0.8"
|
||||
|
||||
"@sinonjs/fake-timers@^6.0.0", "@sinonjs/fake-timers@^6.0.1":
|
||||
version "6.0.1"
|
||||
resolved "https://registry.yarnpkg.com/@sinonjs/fake-timers/-/fake-timers-6.0.1.tgz#293674fccb3262ac782c7aadfdeca86b10c75c40"
|
||||
integrity sha512-MZPUxrmFubI36XS1DI3qmI0YdN1gks62JtFZvxR67ljjSNCeK6U08Zx4msEWOXuofgqUt6zPHSi1H9fbjR/NRA==
|
||||
dependencies:
|
||||
"@sinonjs/commons" "^1.7.0"
|
||||
|
||||
"@sinonjs/formatio@^2.0.0":
|
||||
version "2.0.0"
|
||||
resolved "https://registry.yarnpkg.com/@sinonjs/formatio/-/formatio-2.0.0.tgz#84db7e9eb5531df18a8c5e0bfb6e449e55e654b2"
|
||||
@@ -3611,6 +3618,14 @@
|
||||
"@sinonjs/commons" "^1"
|
||||
"@sinonjs/samsam" "^4.2.0"
|
||||
|
||||
"@sinonjs/formatio@^5.0.1":
|
||||
version "5.0.1"
|
||||
resolved "https://registry.yarnpkg.com/@sinonjs/formatio/-/formatio-5.0.1.tgz#f13e713cb3313b1ab965901b01b0828ea6b77089"
|
||||
integrity sha512-KaiQ5pBf1MpS09MuA0kp6KBQt2JUOQycqVG1NZXvzeaXe5LGFqAKueIS0bw4w0P9r7KuBSVdUk5QjXsUdu2CxQ==
|
||||
dependencies:
|
||||
"@sinonjs/commons" "^1"
|
||||
"@sinonjs/samsam" "^5.0.2"
|
||||
|
||||
"@sinonjs/samsam@^3.0.2", "@sinonjs/samsam@^3.1.0", "@sinonjs/samsam@^3.3.1", "@sinonjs/samsam@^3.3.3":
|
||||
version "3.3.3"
|
||||
resolved "https://registry.yarnpkg.com/@sinonjs/samsam/-/samsam-3.3.3.tgz#46682efd9967b259b81136b9f120fd54585feb4a"
|
||||
@@ -3629,6 +3644,15 @@
|
||||
lodash.get "^4.4.2"
|
||||
type-detect "^4.0.8"
|
||||
|
||||
"@sinonjs/samsam@^5.0.2", "@sinonjs/samsam@^5.0.3":
|
||||
version "5.0.3"
|
||||
resolved "https://registry.yarnpkg.com/@sinonjs/samsam/-/samsam-5.0.3.tgz#86f21bdb3d52480faf0892a480c9906aa5a52938"
|
||||
integrity sha512-QucHkc2uMJ0pFGjJUDP3F9dq5dx8QIaqISl9QgwLOh6P9yv877uONPGXh/OH/0zmM3tW1JjuJltAZV2l7zU+uQ==
|
||||
dependencies:
|
||||
"@sinonjs/commons" "^1.6.0"
|
||||
lodash.get "^4.4.2"
|
||||
type-detect "^4.0.8"
|
||||
|
||||
"@sinonjs/text-encoding@^0.7.1":
|
||||
version "0.7.1"
|
||||
resolved "https://registry.yarnpkg.com/@sinonjs/text-encoding/-/text-encoding-0.7.1.tgz#8da5c6530915653f3a1f38fd5f101d8c3f8079c5"
|
||||
@@ -4050,6 +4074,26 @@
|
||||
resolved "https://registry.yarnpkg.com/@types/parse-json/-/parse-json-4.0.0.tgz#2f8bb441434d163b35fb8ffdccd7138927ffb8c0"
|
||||
integrity sha512-//oorEZjL6sbPcKUaCdIGlIUeH26mgzimjBB77G6XRgnDl/L5wOnpyBGRe/Mmf5CVW3PwEBE1NjiMZ/ssFh4wA==
|
||||
|
||||
"@types/parse5-html-rewriting-stream@5.1.1":
|
||||
version "5.1.1"
|
||||
resolved "https://registry.yarnpkg.com/@types/parse5-html-rewriting-stream/-/parse5-html-rewriting-stream-5.1.1.tgz#a53feb6070b02193b5fc64e3cd81937a0f2503a9"
|
||||
integrity sha512-mjD4nx8WudMCR8EQVlU7Trc2uwKND7LMuftzgsbMmiFTeeW+viKjeqg7VnE0TNjSSQv3z9588K8BsT+N1EBLlg==
|
||||
dependencies:
|
||||
"@types/parse5-sax-parser" "*"
|
||||
|
||||
"@types/parse5-sax-parser@*":
|
||||
version "5.0.1"
|
||||
resolved "https://registry.yarnpkg.com/@types/parse5-sax-parser/-/parse5-sax-parser-5.0.1.tgz#f1e26e82bb09e48cb0c16ff6d1e88aea1e538fd5"
|
||||
integrity sha512-wBEwg10aACLggnb44CwzAA27M1Jrc/8TR16zA61/rKO5XZoi7JSfLjdpXbshsm7wOlM6hpfvwygh40rzM2RsQQ==
|
||||
dependencies:
|
||||
"@types/node" "*"
|
||||
"@types/parse5" "*"
|
||||
|
||||
"@types/parse5@*":
|
||||
version "5.0.2"
|
||||
resolved "https://registry.yarnpkg.com/@types/parse5/-/parse5-5.0.2.tgz#a877a4658f8238c8266faef300ae41c84d72ec8a"
|
||||
integrity sha512-BOl+6KDs4ItndUWUFchy3aEqGdHhw0BC4Uu+qoDonN/f0rbUnJbm71Ulj8Tt9jLFRaAxPLKvdS1bBLfx1qXR9g==
|
||||
|
||||
"@types/parsimmon@^1.3.0":
|
||||
version "1.10.1"
|
||||
resolved "https://registry.yarnpkg.com/@types/parsimmon/-/parsimmon-1.10.1.tgz#d46015ad91128fce06a1a688ab39a2516507f740"
|
||||
@@ -5441,6 +5485,11 @@ asynckit@^0.4.0:
|
||||
resolved "https://registry.yarnpkg.com/asynckit/-/asynckit-0.4.0.tgz#c79ed97f7f34cb8f2ba1bc9790bcc366474b4b79"
|
||||
integrity sha1-x57Zf380y48robyXkLzDZkdLS3k=
|
||||
|
||||
at-least-node@^1.0.0:
|
||||
version "1.0.0"
|
||||
resolved "https://registry.yarnpkg.com/at-least-node/-/at-least-node-1.0.0.tgz#602cd4b46e844ad4effc92a8011a3c46e0238dc2"
|
||||
integrity sha512-+q/t7Ekv1EDY2l6Gda6LLiX14rU9TV20Wa3ofeQmwPFZbOMo9DXrLbOjFaaclkXKWidIaopwAObQDqwWtGUjqg==
|
||||
|
||||
atob-lite@^2.0.0:
|
||||
version "2.0.0"
|
||||
resolved "https://registry.yarnpkg.com/atob-lite/-/atob-lite-2.0.0.tgz#0fef5ad46f1bd7a8502c65727f0367d5ee43d696"
|
||||
@@ -12111,6 +12160,16 @@ fs-extra@8.1.0, fs-extra@^8.1.0:
|
||||
jsonfile "^4.0.0"
|
||||
universalify "^0.1.0"
|
||||
|
||||
fs-extra@9.0.0:
|
||||
version "9.0.0"
|
||||
resolved "https://registry.yarnpkg.com/fs-extra/-/fs-extra-9.0.0.tgz#b6afc31036e247b2466dc99c29ae797d5d4580a3"
|
||||
integrity sha512-pmEYSk3vYsG/bF651KPUXZ+hvjpgWYw/Gc7W9NFUe3ZVLczKKWIij3IKpOrQcdw4TILtibFslZ0UmR8Vvzig4g==
|
||||
dependencies:
|
||||
at-least-node "^1.0.0"
|
||||
graceful-fs "^4.2.0"
|
||||
jsonfile "^6.0.1"
|
||||
universalify "^1.0.0"
|
||||
|
||||
fs-extra@^1.0.0:
|
||||
version "1.0.0"
|
||||
resolved "https://registry.yarnpkg.com/fs-extra/-/fs-extra-1.0.0.tgz#cd3ce5f7e7cb6145883fcae3191e9877f8587950"
|
||||
@@ -13555,16 +13614,7 @@ http-proxy-agent@^2.1.0:
|
||||
agent-base "4"
|
||||
debug "3.1.0"
|
||||
|
||||
http-proxy@^1.17.0:
|
||||
version "1.18.0"
|
||||
resolved "https://registry.yarnpkg.com/http-proxy/-/http-proxy-1.18.0.tgz#dbe55f63e75a347db7f3d99974f2692a314a6a3a"
|
||||
integrity sha512-84I2iJM/n1d4Hdgc6y2+qY5mDaz2PUVjlg9znE9byl+q0uC3DeByqBGReQu5tpLK0TAqTIXScRUV+dg7+bUPpQ==
|
||||
dependencies:
|
||||
eventemitter3 "^4.0.0"
|
||||
follow-redirects "^1.0.0"
|
||||
requires-port "^1.0.0"
|
||||
|
||||
http-proxy@cypress-io/node-http-proxy#9322b4b69b34f13a6f3874e660a35df3305179c6:
|
||||
http-proxy@^1.17.0, http-proxy@cypress-io/node-http-proxy#9322b4b69b34f13a6f3874e660a35df3305179c6:
|
||||
version "1.18.0"
|
||||
resolved "https://codeload.github.com/cypress-io/node-http-proxy/tar.gz/9322b4b69b34f13a6f3874e660a35df3305179c6"
|
||||
dependencies:
|
||||
@@ -15634,6 +15684,15 @@ jsonfile@^4.0.0:
|
||||
optionalDependencies:
|
||||
graceful-fs "^4.1.6"
|
||||
|
||||
jsonfile@^6.0.1:
|
||||
version "6.0.1"
|
||||
resolved "https://registry.yarnpkg.com/jsonfile/-/jsonfile-6.0.1.tgz#98966cba214378c8c84b82e085907b40bf614179"
|
||||
integrity sha512-jR2b5v7d2vIOust+w3wtFKZIfpC2pnRmFAhAC/BuweZFQR8qZzxH1OyrQ10HmdVYiXWkYUqPVsz91cG7EL2FBg==
|
||||
dependencies:
|
||||
universalify "^1.0.0"
|
||||
optionalDependencies:
|
||||
graceful-fs "^4.1.6"
|
||||
|
||||
jsonify@~0.0.0:
|
||||
version "0.0.0"
|
||||
resolved "https://registry.yarnpkg.com/jsonify/-/jsonify-0.0.0.tgz#2c74b6ee41d93ca51b7b5aaee8f503631d252a73"
|
||||
@@ -17857,6 +17916,17 @@ nise@^3.0.1:
|
||||
lolex "^5.0.1"
|
||||
path-to-regexp "^1.7.0"
|
||||
|
||||
nise@^4.0.1:
|
||||
version "4.0.3"
|
||||
resolved "https://registry.yarnpkg.com/nise/-/nise-4.0.3.tgz#9f79ff02fa002ed5ffbc538ad58518fa011dc913"
|
||||
integrity sha512-EGlhjm7/4KvmmE6B/UFsKh7eHykRl9VH+au8dduHLCyWUO/hr7+N+WtTvDUwc9zHuM1IaIJs/0lQ6Ag1jDkQSg==
|
||||
dependencies:
|
||||
"@sinonjs/commons" "^1.7.0"
|
||||
"@sinonjs/fake-timers" "^6.0.0"
|
||||
"@sinonjs/text-encoding" "^0.7.1"
|
||||
just-extend "^4.0.2"
|
||||
path-to-regexp "^1.7.0"
|
||||
|
||||
no-case@^2.2.0:
|
||||
version "2.3.2"
|
||||
resolved "https://registry.yarnpkg.com/no-case/-/no-case-2.3.2.tgz#60b813396be39b3f1288a4c1ed5d1e7d28b464ac"
|
||||
@@ -17874,6 +17944,16 @@ nock@12.0.2:
|
||||
lodash "^4.17.13"
|
||||
propagate "^2.0.0"
|
||||
|
||||
nock@12.0.3:
|
||||
version "12.0.3"
|
||||
resolved "https://registry.yarnpkg.com/nock/-/nock-12.0.3.tgz#83f25076dbc4c9aa82b5cdf54c9604c7a778d1c9"
|
||||
integrity sha512-QNb/j8kbFnKCiyqi9C5DD0jH/FubFGj5rt9NQFONXwQm3IPB0CULECg/eS3AU1KgZb/6SwUa4/DTRKhVxkGABw==
|
||||
dependencies:
|
||||
debug "^4.1.0"
|
||||
json-stringify-safe "^5.0.1"
|
||||
lodash "^4.17.13"
|
||||
propagate "^2.0.0"
|
||||
|
||||
node-abi@^2.7.0:
|
||||
version "2.16.0"
|
||||
resolved "https://registry.yarnpkg.com/node-abi/-/node-abi-2.16.0.tgz#7df94e9c0a7a189f4197ab84bac8089ef5894992"
|
||||
@@ -19084,6 +19164,21 @@ parse-url@^5.0.0:
|
||||
parse-path "^4.0.0"
|
||||
protocols "^1.4.0"
|
||||
|
||||
parse5-html-rewriting-stream@5.1.1:
|
||||
version "5.1.1"
|
||||
resolved "https://registry.yarnpkg.com/parse5-html-rewriting-stream/-/parse5-html-rewriting-stream-5.1.1.tgz#fc18570ba0d09b5091250956d1c3f716ef0a07b7"
|
||||
integrity sha512-rbXBeMlJ3pk3tKxLKAUaqvQTZM5KTohXmZvYEv2gU9sQC70w65BxPsh3PVVnwiVNCnNYDtNZRqCKmiMlfdG07Q==
|
||||
dependencies:
|
||||
parse5 "^5.1.1"
|
||||
parse5-sax-parser "^5.1.1"
|
||||
|
||||
parse5-sax-parser@^5.1.1:
|
||||
version "5.1.1"
|
||||
resolved "https://registry.yarnpkg.com/parse5-sax-parser/-/parse5-sax-parser-5.1.1.tgz#02834a9d08b23ea2d99584841c38be09d5247a15"
|
||||
integrity sha512-9HIh6zd7bF1NJe95LPCUC311CekdOi55R+HWXNCsGY6053DWaMijVKOv1oPvdvPTvFicifZyimBVJ6/qvG039Q==
|
||||
dependencies:
|
||||
parse5 "^5.1.1"
|
||||
|
||||
parse5@4.0.0, parse5@^4.0.0:
|
||||
version "4.0.0"
|
||||
resolved "https://registry.yarnpkg.com/parse5/-/parse5-4.0.0.tgz#6d78656e3da8d78b4ec0b906f7c08ef1dfe3f608"
|
||||
@@ -19101,6 +19196,11 @@ parse5@^3.0.1:
|
||||
dependencies:
|
||||
"@types/node" "*"
|
||||
|
||||
parse5@^5.1.1:
|
||||
version "5.1.1"
|
||||
resolved "https://registry.yarnpkg.com/parse5/-/parse5-5.1.1.tgz#f68e4e5ba1852ac2cadc00f4555fff6c2abb6178"
|
||||
integrity sha512-ugq4DFI0Ptb+WWjAdOK16+u/nHfiIrcE+sh8kZMaM0WllQKLI9rOUq6c2b7cwPkXdzfQESqvoqK6ug7U/Yyzug==
|
||||
|
||||
parseqs@0.0.5:
|
||||
version "0.0.5"
|
||||
resolved "https://registry.yarnpkg.com/parseqs/-/parseqs-0.0.5.tgz#d5208a3738e46766e291ba2ea173684921a8b89d"
|
||||
@@ -20764,6 +20864,16 @@ recast@0.10.33:
|
||||
private "~0.1.5"
|
||||
source-map "~0.5.0"
|
||||
|
||||
recast@0.18.8:
|
||||
version "0.18.8"
|
||||
resolved "https://registry.yarnpkg.com/recast/-/recast-0.18.8.tgz#e745d8b7d6da549a03099ff648c957288f4649a4"
|
||||
integrity sha512-pxiq+ZAF0mYQuhQI+qqr8nFjgmEOFYA3YUVV8dXM7Mz20vs2WyKM1z2W0v80RZ/WICeNw2EeORg+QdDIgAX2ng==
|
||||
dependencies:
|
||||
ast-types "0.13.3"
|
||||
esprima "~4.0.0"
|
||||
private "^0.1.8"
|
||||
source-map "~0.6.1"
|
||||
|
||||
recast@^0.10.10:
|
||||
version "0.10.43"
|
||||
resolved "https://registry.yarnpkg.com/recast/-/recast-0.10.43.tgz#b95d50f6d60761a5f6252e15d80678168491ce7f"
|
||||
@@ -22168,6 +22278,11 @@ sinon-chai@3.4.0:
|
||||
resolved "https://registry.yarnpkg.com/sinon-chai/-/sinon-chai-3.4.0.tgz#06fb88dee80decc565106a3061d380007f21e18d"
|
||||
integrity sha512-BpVxsjEkGi6XPbDXrgWUe7Cb1ZzIfxKUbu/MmH5RoUnS7AXpKo3aIYIyQUg0FMvlUL05aPt7VZuAdaeQhEnWxg==
|
||||
|
||||
sinon-chai@3.5.0:
|
||||
version "3.5.0"
|
||||
resolved "https://registry.yarnpkg.com/sinon-chai/-/sinon-chai-3.5.0.tgz#c9a78304b0e15befe57ef68e8a85a00553f5c60e"
|
||||
integrity sha512-IifbusYiQBpUxxFJkR3wTU68xzBN0+bxCScEaKMjBvAQERg6FnTTc1F17rseLb1tjmkJ23730AXpFI0c47FgAg==
|
||||
|
||||
sinon@1.17.7:
|
||||
version "1.17.7"
|
||||
resolved "https://registry.yarnpkg.com/sinon/-/sinon-1.17.7.tgz#4542a4f49ba0c45c05eb2e9dd9d203e2b8efe0bf"
|
||||
@@ -22256,6 +22371,19 @@ sinon@8.1.1:
|
||||
nise "^3.0.1"
|
||||
supports-color "^7.1.0"
|
||||
|
||||
sinon@9.0.2:
|
||||
version "9.0.2"
|
||||
resolved "https://registry.yarnpkg.com/sinon/-/sinon-9.0.2.tgz#b9017e24633f4b1c98dfb6e784a5f0509f5fd85d"
|
||||
integrity sha512-0uF8Q/QHkizNUmbK3LRFqx5cpTttEVXudywY9Uwzy8bTfZUhljZ7ARzSxnRHWYWtVTeh4Cw+tTb3iU21FQVO9A==
|
||||
dependencies:
|
||||
"@sinonjs/commons" "^1.7.2"
|
||||
"@sinonjs/fake-timers" "^6.0.1"
|
||||
"@sinonjs/formatio" "^5.0.1"
|
||||
"@sinonjs/samsam" "^5.0.3"
|
||||
diff "^4.0.2"
|
||||
nise "^4.0.1"
|
||||
supports-color "^7.1.0"
|
||||
|
||||
sisteransi@^1.0.4:
|
||||
version "1.0.5"
|
||||
resolved "https://registry.yarnpkg.com/sisteransi/-/sisteransi-1.0.5.tgz#134d681297756437cc05ca01370d3a7a571075ed"
|
||||
@@ -24369,6 +24497,11 @@ universalify@^0.1.0, universalify@^0.1.2:
|
||||
resolved "https://registry.yarnpkg.com/universalify/-/universalify-0.1.2.tgz#b646f69be3942dabcecc9d6639c80dc105efaa66"
|
||||
integrity sha512-rBJeI5CXAlmy1pV+617WB9J63U6XcazHHF2f2dbJix4XzpUF0RS3Zbj0FGIOCAva5P/d/GBOYaACQ1w+0azUkg==
|
||||
|
||||
universalify@^1.0.0:
|
||||
version "1.0.0"
|
||||
resolved "https://registry.yarnpkg.com/universalify/-/universalify-1.0.0.tgz#b61a1da173e8435b2fe3c67d29b9adf8594bd16d"
|
||||
integrity sha512-rb6X1W158d7pRQBg5gkR8uPaSfiids68LTJQYOtEUhoJUWBdaQHsuT/EUduxXYxcrt4r5PJ4fuHW1MHT6p0qug==
|
||||
|
||||
unpipe@1.0.0, unpipe@~1.0.0:
|
||||
version "1.0.0"
|
||||
resolved "https://registry.yarnpkg.com/unpipe/-/unpipe-1.0.0.tgz#b2bf4ee8514aae6165b4817829d21b2ef49904ec"
|
||||
|
||||
Reference in New Issue
Block a user