Reduce stylesheet-related memory consumption and improve performance (#4068)

* Memoize getCssRulesString by href to improve perf and memory

address #2366

* Add tests

* Move tests around in preparation for more

* Memoize makePathsAbsoluteToStylesheet as well

* Serve large dynamic css file rather than massive static file

* Update readme to be explicit about directories to run npm scripts

- This confused me each time I needed to get tests running again with the watchers

* Fix bug that causes occasional test failure

* rename spec to match issue instead of PR

* update memoization of snapshot css to account for changes by javascript

* improve snapshot css memory usage and performance

* fix linting error

* try re-ordering the tests

* add existential check to html element

* add back html file

* limit memoized caches to 50 items

* simplify generated css code

* refactor listening to css modification and before window load

* add/revise css snapshot tests

remove tests that needed manual verificaiton in favor of integration tests

* use lodash instead of Array.from

* rename function for consistency

* fix limited map first key implementation

* fix log spec


Co-authored-by: Chris Breiding <chrisbreiding@gmail.com>
This commit is contained in:
Cory Danielson
2019-06-17 06:39:46 -10:00
committed by Brian Mann
parent e44fc780dd
commit d2e098206c
22 changed files with 724 additions and 335 deletions
+2
View File
@@ -26,6 +26,7 @@ If you're developing on the driver, you'll want to run in the normal Cypress GUI
```bash
## run in cypress GUI mode
cd packages/driver
npm run cypress:open
```
@@ -50,6 +51,7 @@ The driver uses a node server to test all of its edge cases, so first start that
```bash
## boot the driver's server
cd packages/driver
npm start
```
+2 -1
View File
@@ -47,8 +47,9 @@
"jsdom": "13.2.0",
"lodash": "4.17.11",
"lolex": "4.1.0",
"methods": "1.1.2",
"md5": "2.2.1",
"method-override": "3.0.0",
"methods": "1.1.2",
"minimatch": "3.0.4",
"minimist": "1.2.0",
"mocha": "cypress-io/mocha#58f6eac05e664fc6b69aa9fba70f1f6b5531a900",
-1
View File
@@ -83,5 +83,4 @@ module.exports = {
contentWindow.alert = callbacks.onAlert
contentWindow.confirm = callbacks.onConfirm
}
+64 -103
View File
@@ -1,91 +1,26 @@
path = require("path")
url = require("url")
_ = require("lodash")
$ = require("jquery")
anyUrlInCssRe = /url\((['"])([^'"]*)\1\)/gm
$SnapshotsCss = require("./snapshots_css")
HIGHLIGHT_ATTR = "data-cypress-el"
reduceText = (arr, fn) ->
_.reduce arr, ((memo, item) -> memo += fn(item)), ""
getCssRulesString = (stylesheet) ->
## some browsers may throw a SecurityError if the stylesheet is cross-domain
## https://developer.mozilla.org/en-US/docs/Web/API/CSSStyleSheet#Notes
## for others, it will just be null
try
if rules = stylesheet.rules or stylesheet.cssRules
reduceText rules, (rule) -> rule.cssText
else
null
catch e
null
screenStylesheetRe = /(screen|all)/
isScreenStylesheet = (stylesheet) ->
media = stylesheet.getAttribute("media")
return not _.isString(media) or screenStylesheetRe.test(media)
getStylesFor = (doc, $$, stylesheets, location) ->
styles = $$(location).find("link[rel='stylesheet'],style")
styles = _.filter(styles, isScreenStylesheet)
_.map styles, (stylesheet) =>
## in cases where we can get the CSS as a string, make the paths
## absolute so that when they're restored by appending them to the page
## in <style> tags, background images and fonts still properly load
if stylesheet.href
## if there's an href, it's a link tag
## return the CSS rules as a string, or, if cross-domain,
## a reference to the stylesheet's href
makePathsAbsoluteToStylesheet(
getCssRulesString(stylesheets[stylesheet.href]),
stylesheet.href
) or {
href: stylesheet.href
}
else
## otherwise, it's a style tag, and we can just grab its content
styleRules = if stylesheet.sheet
then Array.prototype.slice.call(stylesheet.sheet.cssRules).map((rule) -> rule.cssText).join("")
else $$(stylesheet).text()
makePathsAbsoluteToDoc(doc, styleRules)
getDocumentStylesheets = (document = {}) ->
_.reduce document.styleSheets, (memo, stylesheet) ->
memo[stylesheet.href] = stylesheet
return memo
, {}
makePathsAbsoluteToStylesheet = (styles, stylesheetHref) ->
return styles if not _.isString(styles)
stylesheetPath = stylesheetHref.replace(path.basename(stylesheetHref), '')
styles.replace anyUrlInCssRe, (_1, _2, filePath) ->
absPath = url.resolve(stylesheetPath, filePath)
return "url('#{absPath}')"
makePathsAbsoluteToDoc = (doc, styles) ->
return styles if not _.isString(styles)
styles.replace anyUrlInCssRe, (_1, _2, filePath) ->
## the href getter will always resolve an absolute path taking into
## account things like the current URL and the <base> tag
a = doc.createElement("a")
a.href = filePath
return "url('#{a.href}')"
create = ($$, state) ->
getStyles = ->
doc = state("document")
stylesheets = getDocumentStylesheets(doc)
snapshotsCss = $SnapshotsCss.create($$, state)
snapshotsMap = new WeakMap()
return {
headStyles: getStylesFor(doc, $$, stylesheets, "head")
bodyStyles: getStylesFor(doc, $$, stylesheets, "body")
}
getHtmlAttrs = (htmlEl) ->
tmpHtmlEl = document.createElement("html")
_.transform htmlEl?.attributes, (memo, attr) ->
return if not attr.specified
try
## if we can successfully set the attributethen set it on memo
## because it's possible the attribute is completely invalid
tmpHtmlEl.setAttribute(attr.name, attr.value)
memo[attr.name] = attr.value
, {}
replaceIframes = (body) ->
## remove iframes because we don't want extra requests made, JS run, etc
@@ -150,25 +85,52 @@ create = ($$, state) ->
"""
$placeholder[0].src = "data:text/html;charset=utf-8,#{encodeURI(contents)}"
createSnapshot = ($el) ->
getStyles = (snapshot) ->
styleIds = snapshotsMap.get(snapshot)
return {} if not styleIds
return {
headStyles: snapshotsCss.getStylesByIds(styleIds.headStyleIds)
bodyStyles: snapshotsCss.getStylesByIds(styleIds.bodyStyleIds)
}
detachDom = (iframeContents) ->
{ headStyleIds, bodyStyleIds } = snapshotsCss.getStyleIds()
htmlAttrs = getHtmlAttrs(iframeContents.find('html')[0])
$body = iframeContents.find('body')
$body.find('script,link[rel="stylesheet"],style').remove()
snapshot = {
name: "final state"
htmlAttrs
body: $body.detach()
}
snapshotsMap.set(snapshot, { headStyleIds, bodyStyleIds })
return snapshot
createSnapshot = (name, $elToHighlight) ->
## create a unique selector for this el
$el.attr(HIGHLIGHT_ATTR, true) if $el?.attr
$elToHighlight.attr(HIGHLIGHT_ATTR, true) if $elToHighlight?.attr
## TODO: throw error here if cy is undefined!
body = $$("body").clone()
$body = $$("body").clone()
## for the head and body, get an array of all CSS,
## whether it's links or style tags
## if it's same-origin, it will get the actual styles as a string
## it it's cross-domain, it will get a reference to the link's href
{headStyles, bodyStyles} = getStyles()
{ headStyleIds, bodyStyleIds } = snapshotsCss.getStyleIds()
## replaces iframes with placeholders
replaceIframes(body)
replaceIframes($body)
## remove tags we don't want in body
body.find("script,link[rel='stylesheet'],style").remove()
$body.find("script,link[rel='stylesheet'],style").remove()
## here we need to figure out if we're in a remote manual environment
## if so we need to stringify the DOM:
@@ -184,32 +146,31 @@ create = ($$, state) ->
## which would reduce memory, and some CPU operations
## now remove it after we clone
$el.removeAttr(HIGHLIGHT_ATTR) if $el?.removeAttr
tmpHtmlEl = document.createElement("html")
$elToHighlight.removeAttr(HIGHLIGHT_ATTR) if $elToHighlight?.removeAttr
## preserve attributes on the <html> tag
htmlAttrs = _.reduce $$("html")[0]?.attributes, (memo, attr) ->
if attr.specified
try
## if we can successfully set the attribute
## then set it on memo because its possible
## the attribute is completely invalid
tmpHtmlEl.setAttribute(attr.name, attr.value)
memo[attr.name] = attr.value
htmlAttrs = getHtmlAttrs($$("html")[0])
memo
, {}
snapshot = {
name
htmlAttrs
body: $body
}
return {body, htmlAttrs, headStyles, bodyStyles}
snapshotsMap.set(snapshot, { headStyleIds, bodyStyleIds })
return snapshot
return {
createSnapshot
## careful renaming or removing this method, the runner depends on it
detachDom
getStyles
getDocumentStylesheets
onCssModified: snapshotsCss.onCssModified
onBeforeWindowLoad: snapshotsCss.onBeforeWindowLoad
}
module.exports = {
+216
View File
@@ -0,0 +1,216 @@
const path = require('path')
const url = require('url')
const _ = require('lodash')
const md5 = require('md5')
const LimitedMap = require('../util/limited_map')
const $utils = require('../cypress/utils')
const anyUrlInCssRe = /url\((['"])([^'"]*)\1\)/gm
const screenStylesheetRe = /(screen|all)/
const reduceText = (arr, fn) => {
return _.reduce(arr, ((memo, item) => {
return memo += fn(item)
}), '')
}
const isScreenStylesheet = (stylesheet) => {
const media = stylesheet.getAttribute('media')
return !_.isString(media) || screenStylesheetRe.test(media)
}
const getDocumentStylesheets = (doc) => {
if (!doc) return {}
return _.transform(doc.styleSheets, (memo, stylesheet) => {
memo[stylesheet.href] = stylesheet
}, {})
}
const makePathsAbsoluteToDocCache = new LimitedMap(50)
const makePathsAbsoluteToDoc = $utils.memoize((styles, doc) => {
if (!_.isString(styles)) return styles
return styles.replace(anyUrlInCssRe, (_1, _2, filePath) => {
//// the href getter will always resolve an absolute path taking into
//// account things like the current URL and the <base> tag
const a = doc.createElement('a')
a.href = filePath
return `url('${a.href}')`
})
}, makePathsAbsoluteToDocCache)
const makePathsAbsoluteToStylesheetCache = new LimitedMap(50)
const makePathsAbsoluteToStylesheet = $utils.memoize((styles, href) => {
if (!_.isString(styles)) {
return styles
}
const stylesheetPath = href.replace(path.basename(href), '')
return styles.replace(anyUrlInCssRe, (_1, _2, filePath) => {
const absPath = url.resolve(stylesheetPath, filePath)
return `url('${absPath}')`
})
}, makePathsAbsoluteToStylesheetCache)
const getExternalCssContents = (href, stylesheet) => {
//// some browsers may throw a SecurityError if the stylesheet is cross-domain
//// https://developer.mozilla.org/en-US/docs/Web/API/CSSStyleSheet#Notes
//// for others, it will just be null
try {
const rules = stylesheet.rules || stylesheet.cssRules
if (rules) {
const contents = reduceText(rules, (rule) => {
return rule.cssText
})
return makePathsAbsoluteToStylesheet(contents, href)
}
return null
} catch (e) {
return null
}
}
const getInlineCssContents = (stylesheet, $$) => {
if (!stylesheet.sheet) return $$(stylesheet).text()
const rules = stylesheet.sheet.cssRules
return reduceText(rules, (rule) => {
return rule.cssText
})
}
const create = ($$, state) => {
const cssIdToContentsMap = new WeakMap()
const cssHashedContentsToIdMap = new LimitedMap()
const cssHrefToIdMap = new LimitedMap()
const cssHrefToModifiedMap = new LimitedMap()
let newWindow = false
//// we invalidate the cache when css is modified by javascript
const onCssModified = (href) => {
cssHrefToModifiedMap.set(href, { modified: true })
}
//// the lifecycle of a stylesheet is the lifecycle of the window
//// so track this to know when to re-evaluate the cache in case
//// of css being modified by javascript
const onBeforeWindowLoad = () => {
newWindow = true
}
const getStyleId = (href, stylesheet) => {
const hrefModified = cssHrefToModifiedMap.get(href) || {}
const existing = cssHrefToIdMap.get(href)
//// if we've loaded a new window and the css was invalidated due to javascript
//// we need to re-evaluate since this time around javascript might not change the css
if (existing && !hrefModified.modified && !(newWindow && hrefModified.modifiedLast)) {
return existing
}
const cssContents = getExternalCssContents(href, stylesheet)
if (cssContents == null) {
return
}
const hashedCssContents = md5(cssContents)
//// if we already have these css contents stored, don't store them again
const existingId = cssHashedContentsToIdMap.get(hashedCssContents)
//// id just needs to be a new object reference
//// we add the href for debuggability
const id = existingId || { hrefId: href }
cssHrefToIdMap.set(href, id)
//// if we already have these css contents stored, don't store them again
if (!existingId) {
cssHashedContentsToIdMap.set(hashedCssContents, id)
cssIdToContentsMap.set(id, cssContents)
}
if (hrefModified.modified) {
hrefModified.modifiedLast = true
} else if (newWindow) {
hrefModified.modifiedLast = false
}
hrefModified.modified = false
cssHrefToModifiedMap.set(href, hrefModified)
return id
}
const getStyleIdsFor = (doc, $$, stylesheets, location) => {
let styles = $$(location).find('link[rel=\'stylesheet\'],style')
styles = _.filter(styles, isScreenStylesheet)
return _.map(styles, (stylesheet) => {
//// in cases where we can get the CSS as a string, make the paths
//// absolute so that when they're restored by appending them to the page
//// in <style> tags, background images and fonts still properly load
const href = stylesheet.href
//// if there's an href, it's a link tag
//// return the CSS rules as a string, or, if cross-domain,
//// a reference to the stylesheet's href
if (href) {
return getStyleId(href, stylesheets[href]) || { href }
}
//// otherwise, it's a style tag, and we can just grab its content
const cssContents = getInlineCssContents(stylesheet, $$)
return makePathsAbsoluteToDoc(cssContents, doc)
})
}
const getStyleIds = () => {
const doc = state('document')
const stylesheets = getDocumentStylesheets(doc)
const styleIds = {
headStyleIds: getStyleIdsFor(doc, $$, stylesheets, 'head'),
bodyStyleIds: getStyleIdsFor(doc, $$, stylesheets, 'body'),
}
//// after getting the all the styles on the page, it's no longer a new window
newWindow = false
return styleIds
}
const getStylesByIds = (ids) => {
return _.map(ids, (idOrCss) => {
if (_.isString(idOrCss)) {
return idOrCss
}
return cssIdToContentsMap.get(idOrCss) || { href: idOrCss.href }
})
}
return {
getStyleIds,
getStylesByIds,
onCssModified,
onBeforeWindowLoad,
}
}
module.exports = {
create,
}
+4 -1
View File
@@ -29,7 +29,7 @@ $utils = require("./cypress/utils")
proxies = {
runner: "getStartTime getTestsState getEmissions setNumLogs countByTestState getDisplayPropsForLog getConsolePropsForLogById getSnapshotPropsForLogById getErrorByTestId setStartTime resumeAtTest normalizeAll".split(" ")
cy: "getStyles".split(" ")
cy: "detachDom getStyles".split(" ")
}
jqueryProxyFn = ->
@@ -395,6 +395,9 @@ class $Cypress
when "app:window:unload"
@emit("window:unload", args[0])
when "app:css:modified"
@emit("css:modified", args[0])
when "spec:script:error"
@emit("script:error", args...)
+17 -2
View File
@@ -169,6 +169,16 @@ create = (specWindow, Cypress, Cookies, state, config, log) ->
contentWindow.document.hasFocus = ->
focused.documentHasFocus.call(@)
cssModificationSpy = (original, args...) ->
snapshots.onCssModified(@href)
original.apply(@, args)
insertRule = contentWindow.CSSStyleSheet.prototype.insertRule
deleteRule = contentWindow.CSSStyleSheet.prototype.deleteRule
contentWindow.CSSStyleSheet.prototype.insertRule = _.wrap(insertRule, cssModificationSpy)
contentWindow.CSSStyleSheet.prototype.deleteRule = _.wrap(deleteRule, cssModificationSpy)
enqueue = (obj) ->
## if we have a nestedIndex it means we're processing
## nested commands and need to splice them into the
@@ -912,6 +922,8 @@ create = (specWindow, Cypress, Cookies, state, config, log) ->
wrapNativeMethods(contentWindow)
snapshots.onBeforeWindowLoad()
timers.wrap(contentWindow)
onSpecWindowUncaughtException: ->
@@ -954,8 +966,11 @@ create = (specWindow, Cypress, Cookies, state, config, log) ->
## When the function returns true, this prevents the firing of the default event handler.
return true
getStyles: ->
snapshots.getStyles()
detachDom: (args...) ->
snapshots.detachDom(args...)
getStyles: (args...) ->
snapshots.getStyles(args...)
setRunnable: (runnable, hookName) ->
## when we're setting a new runnable
+2 -10
View File
@@ -269,20 +269,12 @@ Log = (cy, state, config, obj) ->
at: null
next: null
{body, htmlAttrs, headStyles, bodyStyles} = cy.createSnapshot(@get("$el"))
obj = {
name: name
body: body
htmlAttrs: htmlAttrs
headStyles: headStyles
bodyStyles: bodyStyles
}
snapshot = cy.createSnapshot(name, @get("$el"))
snapshots = @get("snapshots") ? []
## insert at index 'at' or whatever is the next position
snapshots[options.at or snapshots.length] = obj
snapshots[options.at or snapshots.length] = snapshot
@set("snapshots", snapshots)
+16
View File
@@ -363,4 +363,20 @@ module.exports = {
values
run(0)
memoize: (func, cacheInstance = new Map()) ->
memoized = (args...) ->
key = args[0]
cache = memoized.cache
return cache.get(key) if cache.has(key)
result = func.apply(this, args)
memoized.cache = cache.set(key, result) || cache
return result
memoized.cache = cacheInstance
return memoized
}
+36
View File
@@ -0,0 +1,36 @@
const _ = require('lodash')
// IE doesn't support Array.from or Map.prototype.keys
const getMapKeys = (map) => {
if (_.isFunction(Array.from) && _.isFunction(map.keys)) {
return Array.from(map.keys())
}
const keys = []
map.forEach((key) => {
keys.push(key)
})
return keys
}
class LimitedMap extends Map {
constructor (limit = 100) {
super()
this._limit = limit
}
set (key, value) {
if (this.size === this._limit) {
const firstKey = _.first(getMapKeys(this))
this.delete(firstKey)
}
return super.set(key, value)
}
}
module.exports = LimitedMap
@@ -6,6 +6,13 @@
<body>
Some generic content
<div>
<span class="foo">Foo</span>
<span class="bar">Bar</span>
<span class="baz">Baz</span>
<span class="qux">Qux</span>
</div>
<a href="/">link</a>
<a id="hashchange" href="#hashchange">hashchange</a>
@@ -0,0 +1,3 @@
.bar {
color: red;
}
@@ -0,0 +1,3 @@
.baz {
color: purple;
}
@@ -0,0 +1,3 @@
.print {
color: black;
}
@@ -0,0 +1,213 @@
const $ = Cypress.$.bind(Cypress)
const $SnapshotsCss = require('../../../../src/cy/snapshots_css')
const normalizeStyles = (styles) => {
return styles
.replace(/\s+/gm, '')
.replace(/['"]/gm, '\'')
}
const addStyles = (styles, to) => {
return new Promise((resolve) => {
$(styles)
.load(() => {
return resolve()
})
.appendTo(cy.$$(to))
})
}
describe('driver/src/cy/snapshots_css', () => {
let snapshotCss
beforeEach(() => {
snapshotCss = $SnapshotsCss.create(cy.$$, cy.state)
cy.viewport(400, 600)
cy.visit('/fixtures/generic.html').then(() => {
return Cypress.Promise.all([
addStyles('<link rel="stylesheet" href="/fixtures/generic_styles.css" />', 'head'),
addStyles('<style>p { color: blue; }</style>', 'head'),
addStyles('<link media="screen" rel="stylesheet" href="http://localhost:3501/fixtures/generic_styles.css" />', 'head'),
addStyles('<link media="print" rel="stylesheet" href="/fixtures/generic_styles_print.css" />', 'head'),
addStyles('<link media="all" rel="stylesheet" href="/fixtures/generic_styles_2.css" />', 'body'),
addStyles('<link rel="stylesheet" href="/fixtures/generic_styles_3.css" />', 'body'),
])
})
})
context('.getStyleIds', () => {
it('returns IDs for cached CSS contents', () => {
const { headStyleIds, bodyStyleIds } = snapshotCss.getStyleIds()
const another = snapshotCss.getStyleIds()
expect(headStyleIds).to.have.length(3)
expect(headStyleIds[0]).to.eql({ hrefId: 'http://localhost:3500/fixtures/generic_styles.css' })
expect(bodyStyleIds).to.have.length(2)
expect(bodyStyleIds).to.eql([{ hrefId: 'http://localhost:3500/fixtures/generic_styles_2.css' }, { hrefId: 'http://localhost:3500/fixtures/generic_styles_3.css' }])
// IDs for 2 of the same stylesheets should have referential equality
expect(headStyleIds[0]).to.equal(another.headStyleIds[0])
})
it('returns strings for inline stylesheets', () => {
const { headStyleIds } = snapshotCss.getStyleIds()
expect(headStyleIds[1]).to.equal('p { color: blue; }')
})
it('returns { href } object for cross-origin stylesheets', () => {
const { headStyleIds } = snapshotCss.getStyleIds()
expect(headStyleIds[2]).to.eql({ href: 'http://localhost:3501/fixtures/generic_styles.css' })
})
it('works for media-less stylesheets', () => {
const { headStyleIds } = snapshotCss.getStyleIds()
expect(headStyleIds[0]).to.eql({ hrefId: 'http://localhost:3500/fixtures/generic_styles.css' })
})
it('works for media=screen stylesheets', () => {
const { headStyleIds } = snapshotCss.getStyleIds()
expect(headStyleIds[2]).to.eql({ href: 'http://localhost:3501/fixtures/generic_styles.css' })
})
it('works for media=all stylesheets', () => {
const { bodyStyleIds } = snapshotCss.getStyleIds()
expect(bodyStyleIds[0]).to.eql({ hrefId: 'http://localhost:3500/fixtures/generic_styles_2.css' })
})
it('ignores other media stylesheets', () => {
const { headStyleIds } = snapshotCss.getStyleIds()
expect(headStyleIds).to.have.length(3)
})
it('returns new id if css has been modified', () => {
const idsBefore = snapshotCss.getStyleIds()
cy.state('document').styleSheets[0].insertRule('.qux { color: orange; }')
snapshotCss.onCssModified('http://localhost:3500/fixtures/generic_styles.css')
const idsAfter = snapshotCss.getStyleIds()
expect(idsBefore.headStyleIds).to.have.length(3)
expect(idsAfter.headStyleIds).to.have.length(3)
expect(idsAfter.headStyleIds[0]).to.eql({ hrefId: 'http://localhost:3500/fixtures/generic_styles.css' })
// same href, but id should be referentially NOT equal
expect(idsBefore.headStyleIds[0]).not.to.equal(idsAfter.headStyleIds[0])
})
it('returns same id after css has been modified until a new window', () => {
cy.state('document').styleSheets[0].insertRule('.qux { color: orange; }')
snapshotCss.onCssModified('http://localhost:3500/fixtures/generic_styles.css')
const ids1 = snapshotCss.getStyleIds()
const ids2 = snapshotCss.getStyleIds()
const ids3 = snapshotCss.getStyleIds()
expect(ids1.headStyleIds[0]).to.eql({ hrefId: 'http://localhost:3500/fixtures/generic_styles.css' })
expect(ids1.headStyleIds[0]).to.equal(ids2.headStyleIds[0])
expect(ids2.headStyleIds[0]).to.equal(ids3.headStyleIds[0])
cy.state('document').styleSheets[0].deleteRule(0) // need to change contents or they will map to same id
snapshotCss.onBeforeWindowLoad()
const ids4 = snapshotCss.getStyleIds()
expect(ids4.headStyleIds[0]).to.eql({ hrefId: 'http://localhost:3500/fixtures/generic_styles.css' })
expect(ids3.headStyleIds[0]).not.to.equal(ids4.headStyleIds[0])
})
it('returns same id if css has been modified but yields same contents', () => {
const ids1 = snapshotCss.getStyleIds()
cy.state('document').styleSheets[0].insertRule('.qux { color: orange; }')
snapshotCss.onCssModified('http://localhost:3500/fixtures/generic_styles.css')
cy.state('document').styleSheets[0].deleteRule(0)
snapshotCss.onCssModified('http://localhost:3500/fixtures/generic_styles.css')
const ids2 = snapshotCss.getStyleIds()
expect(ids2.headStyleIds[0]).to.eql({ hrefId: 'http://localhost:3500/fixtures/generic_styles.css' })
expect(ids1.headStyleIds[0]).to.equal(ids2.headStyleIds[0])
})
})
context('.getStylesByIds', () => {
let getStyles
beforeEach(() => {
getStyles = () => {
const { headStyleIds, bodyStyleIds } = snapshotCss.getStyleIds()
const headStyles = snapshotCss.getStylesByIds(headStyleIds)
const bodyStyles = snapshotCss.getStylesByIds(bodyStyleIds)
return { headStyles, bodyStyles }
}
})
it('returns array of css styles for given ids', () => {
const { headStyles, bodyStyles } = getStyles()
expect(headStyles[0]).to.equal('.foo { color: green; }')
expect(headStyles[1]).to.equal('p { color: blue; }')
expect(bodyStyles[0]).to.eql('.bar { color: red; }')
expect(bodyStyles[1]).to.eql('.baz { color: purple; }')
})
it('returns { href } object for cross-origin stylesheets', () => {
const { headStyles } = getStyles()
expect(headStyles[2]).to.eql({ href: 'http://localhost:3501/fixtures/generic_styles.css' })
})
it('includes rules injected by JavaScript', () => {
const styleEl = document.createElement('style')
$(styleEl).appendTo(cy.$$('head'))
styleEl.sheet.insertRule('.foo { color: red; }', 0)
const { headStyles } = getStyles()
expect(headStyles[3]).to.equal('.foo { color: red; }')
})
it('replaces CSS paths of style tags with absolute paths', () => {
const styles = `
<style>
@font-face {
font-family: 'Some Font';
src: url('../fonts/some-font.eot');
src: url('../fonts/some-font.eot?#iefix') format('embedded-opentype'), url('../fonts/some-font.woff2') format('woff2'), url('../fonts/some-font.woff') format('woff'), url('../fonts/some-font.ttf') format('truetype'), url('../fonts/some-font.svg#glyphicons_halflingsregular') format('svg');
}
</style>
`
$(styles).appendTo(cy.$$('head'))
const { headStyles } = getStyles()
expect(normalizeStyles(headStyles[3])).to.include(normalizeStyles(`
@font-face {
font-family: "Some Font";
src: url('http://localhost:3500/fonts/some-font.eot?#iefix') format("embedded-opentype"), url('http://localhost:3500/fonts/some-font.woff2') format("woff2"), url('http://localhost:3500/fonts/some-font.woff') format("woff"), url('http://localhost:3500/fonts/some-font.ttf') format("truetype"), url('http://localhost:3500/fonts/some-font.svg#glyphicons_halflingsregular') format("svg");
}
`))
})
it('replaces CSS paths of local stylesheets with absolute paths', () => {
return addStyles('<link rel="stylesheet" href="nested/with_paths.css" />', 'head').then(() => {
const { headStyles } = getStyles()
expect(normalizeStyles(headStyles[3])).to.include(normalizeStyles(`
@font-face {
font-family: 'Some Font';
src: url('http://localhost:3500/fixtures/fonts/some-font.eot?#iefix') format('embedded-opentype'), url('http://localhost:3500/fixtures/fonts/some-font.woff2') format('woff2'), url('http://localhost:3500/fixtures/fonts/some-font.woff') format('woff'), url('http://localhost:3500/fixtures/fonts/some-font.ttf') format('truetype'), url('http://localhost:3500/fixtures/fonts/some-font.svg#glyphicons_halflingsregular') format('svg');
}
`))
})
})
})
})
@@ -1,11 +1,6 @@
$ = Cypress.$.bind(Cypress)
$Snapshots = require("../../../../src/cy/snapshots")
normalizeStyles = (styles) ->
styles
.replace(/\s+/gm, "")
.replace(/['"]/gm, "'")
describe "driver/src/cy/snapshots", ->
context "invalid snapshot html", ->
beforeEach ->
@@ -53,19 +48,19 @@ describe "driver/src/cy/snapshots", ->
it "does not clone scripts", ->
$("<script type='text/javascript' />").appendTo(cy.$$("body"))
{ body } = cy.createSnapshot(@$el)
{ body } = cy.createSnapshot(null, @$el)
expect(body.find("script")).not.to.exist
it "does not clone css stylesheets", ->
$("<link rel='stylesheet' />").appendTo(cy.$$("body"))
{ body } = cy.createSnapshot(@$el)
{ body } = cy.createSnapshot(null, @$el)
expect(body.find("link")).not.to.exist
it "does not clone style tags", ->
$("<style>.foo { color: blue }</style>").appendTo(cy.$$("body"))
{ body } = cy.createSnapshot(@$el)
{ body } = cy.createSnapshot(null, @$el)
expect(body.find("style")).not.to.exist
it "preserves classes on the <html> tag", ->
@@ -74,215 +69,56 @@ describe "driver/src/cy/snapshots", ->
$html[0].id = "baz"
$html.css("margin", "10px")
{ htmlAttrs } = cy.createSnapshot(@$el)
{ htmlAttrs } = cy.createSnapshot(null, @$el)
expect(htmlAttrs).to.eql({
class: "foo bar"
id: "baz"
style: "margin: 10px;"
})
it "provides contents of style tags in head", ->
$("<style>.foo { color: red }</style>").appendTo(cy.$$("head"))
{ headStyles } = cy.createSnapshot(@$el)
expect(headStyles[0]).to.include(".foo { color: red; }")
it "provides contents of style tags in head for injected rules", ->
styleEl = document.createElement("style");
$(styleEl).appendTo(cy.$$("head"))
styleEl.sheet.insertRule(".foo { color: red; }", 0)
{ headStyles } = cy.createSnapshot(@$el)
expect(headStyles[0]).to.include(".foo { color: red; }")
it "provides contents of local stylesheet links in head", (done) ->
onLoad = ->
## need to for appended stylesheet to load
{ headStyles } = cy.createSnapshot(@$el)
expect(headStyles[0]).to.include(".foo { color: green; }")
done()
$("<link rel='stylesheet' href='generic_styles.css' />")
.load(onLoad)
.appendTo(cy.$$("head"))
it "provides media-less stylesheets", (done) ->
onLoad = ->
## need to for appended stylesheet to load
{ headStyles } = cy.createSnapshot(@$el)
expect(headStyles[0]).to.include(".foo { color: green; }")
done()
$("<link rel='stylesheet' href='generic_styles.css' />")
.load(onLoad)
.appendTo(cy.$$("head"))
it "provides media=screen stylesheets", (done) ->
onLoad = ->
## need to for appended stylesheet to load
{ headStyles } = cy.createSnapshot(@$el)
expect(headStyles[0]).to.include(".foo { color: green; }")
done()
$("<link rel='stylesheet' href='generic_styles.css' media='screen' />")
.load(onLoad)
.appendTo(cy.$$("head"))
it "provides media=all stylesheets", (done) ->
onLoad = ->
## need to for appended stylesheet to load
{ headStyles } = cy.createSnapshot(@$el)
expect(headStyles[0]).to.include(".foo { color: green; }")
done()
$("<link rel='stylesheet' href='generic_styles.css' media='all' />")
.load(onLoad)
.appendTo(cy.$$("head"))
it "does not provide non-screen stylesheets", (done) ->
onLoad = ->
## need to for appended stylesheet to load
{ headStyles } = cy.createSnapshot(@$el)
expect(headStyles).to.have.length(0)
done()
$("<link rel='stylesheet' href='generic_styles.css' media='print' />")
.load(onLoad)
.appendTo(cy.$$("head"))
it "provides object with href of external stylesheets in head", (done) ->
onLoad = ->
## need to for appended stylesheet to load
{ headStyles } = cy.createSnapshot(@$el)
expect(headStyles[0]).to.deep.eq({href: "http://localhost:3501/fixtures/generic_styles.css"})
done()
$("<link rel='stylesheet' href='http://localhost:3501/fixtures/generic_styles.css' />")
.load(onLoad)
.appendTo(cy.$$("head"))
it "provides contents of style tags in body", ->
$("<style>.foo { color: red }</style>").appendTo(cy.$$("body"))
{bodyStyles} = cy.createSnapshot(@$el)
expect(bodyStyles[bodyStyles.length - 1]).to.include(".foo { color: red; }")
it "provides contents of local stylesheet links in body", (done) ->
onLoad = ->
## need to for appended stylesheet to load
{bodyStyles} = cy.createSnapshot(@$el)
expect(bodyStyles[bodyStyles.length - 1]).to.include(".foo { color: green; }")
done()
$("<link rel='stylesheet' href='generic_styles.css' />")
.load(onLoad)
.appendTo(cy.$$("body"))
it "provides object with href of external stylesheets in body", (done) ->
onLoad = ->
## need to for appended stylesheet to load
{bodyStyles} = cy.createSnapshot(@$el)
expect(bodyStyles[bodyStyles.length - 1]).to.eql({href: "http://localhost:3501/fixtures/generic_styles.css"})
done()
$("<link rel='stylesheet' href='http://localhost:3501/fixtures/generic_styles.css' />")
.load(onLoad)
.appendTo(cy.$$("body"))
it "sets data-cypress-el attr", ->
attr = cy.spy(@$el, "attr")
cy.createSnapshot(@$el)
cy.createSnapshot(null, @$el)
expect(attr).to.be.calledWith("data-cypress-el", true)
it "removes data-cypress-el attr", ->
cy.createSnapshot(@$el)
cy.createSnapshot(null, @$el)
expect(@$el.attr("data-cypress-el")).to.be.undefined
it "replaces CSS paths of style tags with absolute paths", ->
styles = """
<style>
@font-face {
font-family: 'Some Font';
src: url('../fonts/some-font.eot');
src: url('../fonts/some-font.eot?#iefix') format('embedded-opentype'), url('../fonts/some-font.woff2') format('woff2'), url('../fonts/some-font.woff') format('woff'), url('../fonts/some-font.ttf') format('truetype'), url('../fonts/some-font.svg#glyphicons_halflingsregular') format('svg');
}
</style>
"""
$(styles).appendTo(cy.$$("head"))
## need to wait a tick for appended stylesheet to take affect
{ headStyles } = cy.createSnapshot(@$el)
expect(headStyles[0].replace(/\s+/gm, "")).to.include """
@font-face {
font-family: "Some Font";
src: url('http://localhost:3500/fonts/some-font.eot?#iefix') format("embedded-opentype"), url('http://localhost:3500/fonts/some-font.woff2') format("woff2"), url('http://localhost:3500/fonts/some-font.woff') format("woff"), url('http://localhost:3500/fonts/some-font.ttf') format("truetype"), url('http://localhost:3500/fonts/some-font.svg#glyphicons_halflingsregular') format("svg");
}
""".replace(/\s+/gm, "")
it "replaces CSS paths of local stylesheets with absolute paths", (done) ->
loadFn = ->
## need to wait a tick for appended stylesheet to take affect
{ headStyles } = cy.createSnapshot(@$el)
expect(normalizeStyles(headStyles[0])).to.include(normalizeStyles("""
@font-face {
font-family: 'Some Font';
src: url('http://localhost:3500/fixtures/fonts/some-font.eot?#iefix') format('embedded-opentype'), url('http://localhost:3500/fixtures/fonts/some-font.woff2') format('woff2'), url('http://localhost:3500/fixtures/fonts/some-font.woff') format('woff'), url('http://localhost:3500/fixtures/fonts/some-font.ttf') format('truetype'), url('http://localhost:3500/fixtures/fonts/some-font.svg#glyphicons_halflingsregular') format('svg');
}
"""))
done()
$("<link rel='stylesheet' href='nested/with_paths.css' />")
.load(loadFn)
.appendTo(cy.$$("head"))
context "iframes", ->
it "replaces with placeholders that have src in content", ->
$("<iframe src='generic.html' />").appendTo(cy.$$("body"))
{ body } = cy.createSnapshot(@$el)
{ body } = cy.createSnapshot(null, @$el)
expect(body.find("iframe").length).to.equal(1)
expect(body.find("iframe")[0].src).to.include("generic.html")
it "placeholders have same id", ->
$("<iframe id='foo-bar' />").appendTo(cy.$$("body"))
{ body } = cy.createSnapshot(@$el)
{ body } = cy.createSnapshot(null, @$el)
expect(body.find("iframe")[0].id).to.equal("foo-bar")
it "placeholders have same classes", ->
$("<iframe class='foo bar' />").appendTo(cy.$$("body"))
{ body } = cy.createSnapshot(@$el)
{ body } = cy.createSnapshot(null, @$el)
expect(body.find("iframe")[0].className).to.equal("foo bar")
it "placeholders have inline styles", ->
$("<iframe style='margin: 40px' />").appendTo(cy.$$("body"))
{ body } = cy.createSnapshot(@$el)
{ body } = cy.createSnapshot(null, @$el)
expect(body.find("iframe").css("margin")).to.equal("40px")
it "placeholders have width set to outer width", ->
$("<iframe style='width: 40px; padding: 20px; border: solid 5px' />").appendTo(cy.$$("body"))
{ body } = cy.createSnapshot(@$el)
{ body } = cy.createSnapshot(null, @$el)
expect(body.find("iframe").css("width")).to.equal("90px")
it "placeholders have height set to outer height", ->
$("<iframe style='height: 40px; padding: 10px; border: solid 5px' />").appendTo(cy.$$("body"))
{ body } = cy.createSnapshot(@$el)
{ body } = cy.createSnapshot(null, @$el)
expect(body.find("iframe").css("height")).to.equal("70px")
context ".getDocumentStylesheets", ->
it "returns empty obj when no document", ->
fn = ->
snapshot = $Snapshots.create(fn, fn)
expect(snapshot.getDocumentStylesheets(null)).to.deep.eq({})
@@ -19,7 +19,7 @@ describe('src/cypress/log', function () {
const log = this.log({ '$el': div })
const result = log.snapshot()
expect(this.cy.createSnapshot).to.be.calledWith(div)
expect(this.cy.createSnapshot).to.be.calledWith(undefined, div)
expect(result).to.equal(log)
})
@@ -1,3 +1,5 @@
LimitedMap = require("../../../../src/util/limited_map")
_ = Cypress._
$utils = Cypress.utils
Promise = Cypress.Promise
@@ -58,3 +60,45 @@ describe "driver/src/cypress/utils", ->
expect(err2.message).to.eq("\n\nbar")
expect(err2.stack).to.eq("Error: \n\nbar\n" + stack)
context ".memoize", ->
it "runs the function the first time", ->
fn = cy.stub().returns("output")
memoizedFn = $utils.memoize(fn)
result = memoizedFn("input")
expect(fn).to.be.calledWith("input")
expect(result).to.equal("output")
it "runs the function for unique first arguments", ->
fn = cy.stub().returns("output")
memoizedFn = $utils.memoize(fn)
result1 = memoizedFn("input-1")
result2 = memoizedFn("input-2")
expect(fn).to.be.calledWith("input-1")
expect(fn).to.be.calledWith("input-2")
expect(fn).to.be.calledTwice
expect(result1).to.equal("output")
expect(result2).to.equal("output")
it "returns cached return value if first argument is the same", ->
fn = cy.stub().returns("output")
memoizedFn = $utils.memoize(fn)
result1 = memoizedFn("input")
result2 = memoizedFn("input")
expect(fn).to.be.calledWith("input")
expect(fn).to.be.calledOnce
expect(result1).to.equal("output")
expect(result2).to.equal("output")
it "accepts a cache instance to use as the second argument", ->
fn = cy.stub().returns("output")
## LimitedMap(2) only holds on to 2 items at a time and clears older ones
memoizedFn = $utils.memoize(fn, new LimitedMap(2))
memoizedFn("input-1")
memoizedFn("input-2")
expect(fn).to.be.calledTwice
memoizedFn("input-3")
expect(fn).to.be.calledThrice
memoizedFn("input-1")
## cache for input-1 is cleared, so it calls the function again
expect(fn.callCount).to.be.equal(4)
@@ -0,0 +1,63 @@
const LimitedMap = require('../../../../src/util/limited_map')
const _ = Cypress._
describe('driver/src/util/limited_map', () => {
it('has all the methods of a Map', () => {
const limitedMap = new LimitedMap()
expect(limitedMap.set).to.be.a('function')
expect(limitedMap.get).to.be.a('function')
expect(limitedMap.delete).to.be.a('function')
expect(limitedMap.has).to.be.a('function')
expect(limitedMap.size).to.be.a('number')
})
it('remove old entries when over limit, default 100', () => {
const limitedMap = new LimitedMap()
_.each(_.times(100), (i) => {
limitedMap.set(`foo-${i}`, i)
})
expect(limitedMap.size).to.equal(100)
expect(Array.from(limitedMap.values())[0]).to.equal(0)
expect(Array.from(limitedMap.values())[99]).to.equal(99)
limitedMap.set(`foo-${100}`, 100)
expect(limitedMap.size).to.equal(100)
expect(Array.from(limitedMap.values())[0]).to.equal(1)
expect(Array.from(limitedMap.values())[99]).to.equal(100)
limitedMap.set(`foo-${101}`, 101)
expect(limitedMap.size).to.equal(100)
expect(Array.from(limitedMap.values())[0]).to.equal(2)
expect(Array.from(limitedMap.values())[99]).to.equal(101)
limitedMap.set(`foo-${102}`, 102)
expect(limitedMap.size).to.equal(100)
expect(Array.from(limitedMap.values())[0]).to.equal(3)
expect(Array.from(limitedMap.values())[99]).to.equal(102)
})
it('accepts limit as first parameter', () => {
const limitedMap = new LimitedMap(5)
_.each(_.times(5), (i) => {
limitedMap.set(`foo-${i}`, i)
})
expect(limitedMap.size).to.equal(5)
expect(Array.from(limitedMap.values())[0]).to.equal(0)
expect(Array.from(limitedMap.values())[4]).to.equal(4)
limitedMap.set(`foo-${5}`, 5)
expect(limitedMap.size).to.equal(5)
expect(Array.from(limitedMap.values())[0]).to.equal(1)
expect(Array.from(limitedMap.values())[4]).to.equal(5)
limitedMap.set(`foo-${6}`, 6)
expect(limitedMap.size).to.equal(5)
expect(Array.from(limitedMap.values())[0]).to.equal(2)
expect(Array.from(limitedMap.values())[4]).to.equal(6)
})
})
+10 -26
View File
@@ -51,38 +51,22 @@ export default class AutIframe {
return this._contents() && this._contents().find('body')
}
detachDom = (Cypress) => {
const contents = this._contents()
const { headStyles, bodyStyles } = Cypress.getStyles()
let htmlAttrs = {}
detachDom = () => {
const Cypress = eventManager.getCypress()
if (contents.find('html')[0]) {
htmlAttrs = _.transform(contents.find('html')[0].attributes, (memo, attr) => {
if (attr.specified) {
memo[attr.name] = attr.value
}
}, {})
}
if (!Cypress) return
const $body = contents.find('body')
$body.find('script,link[rel="stylesheet"],style').remove()
return {
body: $body.detach(),
htmlAttrs,
headStyles,
bodyStyles,
}
return Cypress.detachDom(this._contents())
}
restoreDom = ({ body, htmlAttrs, headStyles, bodyStyles }) => {
restoreDom = (snapshot) => {
const Cypress = eventManager.getCypress()
const { headStyles, bodyStyles } = Cypress ? Cypress.getStyles(snapshot) : {}
const { body, htmlAttrs } = snapshot
const contents = this._contents()
const $html = contents.find('html')
this._replaceHtmlAttrs($html, htmlAttrs)
this._replaceHeadStyles(headStyles)
// remove the old body and replace with restored one
@@ -113,7 +97,7 @@ export default class AutIframe {
})
}
_replaceHeadStyles (styles) {
_replaceHeadStyles (styles = []) {
const $head = this._contents().find('head')
const existingStyles = $head.find('link[rel="stylesheet"],style')
@@ -165,7 +149,7 @@ export default class AutIframe {
}
}
_insertBodyStyles ($body, styles) {
_insertBodyStyles ($body, styles = []) {
_.each(styles, (style) => {
$body.append(style.href ? this._linkTag(style) : this._styleTag(style))
})
+5 -6
View File
@@ -156,7 +156,7 @@ export default class IframeModel {
this._updateViewport(this.originalState)
this._updateUrl(this.originalState.url)
this.restoreDom(this.originalState)
this.restoreDom(this.originalState.snapshot)
this._clearMessage()
this.originalState = null
@@ -208,17 +208,16 @@ export default class IframeModel {
}
_storeOriginalState () {
const originalState = this.detachDom()
const finalSnapshot = this.detachDom()
if (!originalState) return
if (!finalSnapshot) return
const { body, htmlAttrs, headStyles, bodyStyles } = originalState
const { body, htmlAttrs } = finalSnapshot
this.originalState = {
body,
htmlAttrs,
headStyles,
bodyStyles,
snapshot: finalSnapshot,
url: this.state.url,
viewportWidth: this.state.width,
viewportHeight: this.state.height,
+1 -8
View File
@@ -9,7 +9,6 @@ import AutIframe from './aut-iframe'
import ScriptError from '../errors/script-error'
import SnapshotControls from './snapshot-controls'
import eventManager from '../lib/event-manager'
import IframeModel from './iframe-model'
import logger from '../lib/logger'
import selectorPlaygroundModel from '../selector-playground/selector-playground-model'
@@ -74,13 +73,7 @@ export default class Iframes extends Component {
removeHeadStyles: this.autIframe.removeHeadStyles,
restoreDom: this.autIframe.restoreDom,
highlightEl: this.autIframe.highlightEl,
detachDom: () => {
const Cypress = eventManager.getCypress()
if (Cypress) {
return this.autIframe.detachDom(Cypress)
}
},
detachDom: this.autIframe.detachDom,
snapshotControls: (snapshotProps) => (
<SnapshotControls
eventManager={this.props.eventManager}