electron/spec/chromium-spec.js
Jacob a7c2f79a94 fix: prevent silent failure when DOM storage quota exceeded (#20899)
* test: update DOM storage quota limits test

* fix: update dom_storage_limits.patch (fixes #13465)

The previous version of this patch did not include
changes required to circumvent the quota enforcement
performed by StorageAreaImpl. Consequently when
the quota was exceeded, things still "appeared to
work" at first but then would later fail silently.
That is, the cache would be updated but the backing
store would not.

This could be fixed by disabling the code below
(from `content/browser/dom_storage/storage_area_impl.cc`)
```
  // Only check quota if the size is increasing, this allows
  // shrinking changes to pre-existing maps that are over budget.
  if (new_item_size > old_item_size && new_storage_used > max_size_) {
    if (map_state_ == MapState::LOADED_KEYS_ONLY) {
      receivers_.ReportBadMessage(
          "The quota in browser cannot exceed when there is only one "
          "renderer.");
    } else {
      std::move(callback).Run(false);
    }
    return;
  }
```

However, since this seems to have some unintended side-effects
(see updated notes in dom_storage_limits.patch) it seems
more prudent to simply increase the quota to a larger
yet still reasonable size rather than attempt to circumvent
the storage quota altogether.
2019-11-22 11:35:54 -08:00

576 lines
18 KiB
JavaScript

const { expect } = require('chai')
const fs = require('fs')
const http = require('http')
const path = require('path')
const ws = require('ws')
const url = require('url')
const ChildProcess = require('child_process')
const { ipcRenderer } = require('electron')
const { emittedOnce } = require('./events-helpers')
const { resolveGetters } = require('./expect-helpers')
const features = process.electronBinding('features')
/* Most of the APIs here don't use standard callbacks */
/* eslint-disable standard/no-callback-literal */
describe('chromium feature', () => {
const fixtures = path.resolve(__dirname, 'fixtures')
let listener = null
afterEach(() => {
if (listener != null) {
window.removeEventListener('message', listener)
}
listener = null
})
describe('heap snapshot', () => {
it('does not crash', function () {
process.electronBinding('v8_util').takeHeapSnapshot()
})
})
describe('navigator.webkitGetUserMedia', () => {
it('calls its callbacks', (done) => {
navigator.webkitGetUserMedia({
audio: true,
video: false
}, () => done(),
() => done())
})
})
describe('navigator.language', () => {
it('should not be empty', () => {
expect(navigator.language).to.not.equal('')
})
})
describe('navigator.geolocation', () => {
before(function () {
if (!features.isFakeLocationProviderEnabled()) {
return this.skip()
}
})
it('returns position when permission is granted', (done) => {
navigator.geolocation.getCurrentPosition((position) => {
expect(position).to.have.a.property('coords')
expect(position).to.have.a.property('timestamp')
done()
}, (error) => {
done(error)
})
})
})
describe('window.open', () => {
it('returns a BrowserWindowProxy object', () => {
const b = window.open('about:blank', '', 'show=no')
expect(b.closed).to.be.false()
expect(b.constructor.name).to.equal('BrowserWindowProxy')
b.close()
})
it('accepts "nodeIntegration" as feature', (done) => {
let b = null
listener = (event) => {
expect(event.data.isProcessGlobalUndefined).to.be.true()
b.close()
done()
}
window.addEventListener('message', listener)
b = window.open(`file://${fixtures}/pages/window-opener-node.html`, '', 'nodeIntegration=no,show=no')
})
it('inherit options of parent window', (done) => {
let b = null
listener = (event) => {
const width = outerWidth
const height = outerHeight
expect(event.data).to.equal(`size: ${width} ${height}`)
b.close()
done()
}
window.addEventListener('message', listener)
b = window.open(`file://${fixtures}/pages/window-open-size.html`, '', 'show=no')
})
it('disables node integration when it is disabled on the parent window', (done) => {
let b = null
listener = (event) => {
expect(event.data.isProcessGlobalUndefined).to.be.true()
b.close()
done()
}
window.addEventListener('message', listener)
const windowUrl = require('url').format({
pathname: `${fixtures}/pages/window-opener-no-node-integration.html`,
protocol: 'file',
query: {
p: `${fixtures}/pages/window-opener-node.html`
},
slashes: true
})
b = window.open(windowUrl, '', 'nodeIntegration=no,show=no')
})
it('disables the <webview> tag when it is disabled on the parent window', (done) => {
let b = null
listener = (event) => {
expect(event.data.isWebViewGlobalUndefined).to.be.true()
b.close()
done()
}
window.addEventListener('message', listener)
const windowUrl = require('url').format({
pathname: `${fixtures}/pages/window-opener-no-webview-tag.html`,
protocol: 'file',
query: {
p: `${fixtures}/pages/window-opener-webview.html`
},
slashes: true
})
b = window.open(windowUrl, '', 'webviewTag=no,nodeIntegration=yes,show=no')
})
it('does not override child options', (done) => {
let b = null
const size = {
width: 350,
height: 450
}
listener = (event) => {
expect(event.data).to.equal(`size: ${size.width} ${size.height}`)
b.close()
done()
}
window.addEventListener('message', listener)
b = window.open(`file://${fixtures}/pages/window-open-size.html`, '', 'show=no,width=' + size.width + ',height=' + size.height)
})
it('throws an exception when the arguments cannot be converted to strings', () => {
expect(() => {
window.open('', { toString: null })
}).to.throw('Cannot convert object to primitive value')
expect(() => {
window.open('', '', { toString: 3 })
}).to.throw('Cannot convert object to primitive value')
})
it('does not throw an exception when the features include webPreferences', () => {
let b = null
expect(() => {
b = window.open('', '', 'webPreferences=')
}).to.not.throw()
b.close()
})
})
describe('window.opener', () => {
it('is not null for window opened by window.open', (done) => {
let b = null
listener = (event) => {
expect(event.data).to.equal('object')
b.close()
done()
}
window.addEventListener('message', listener)
b = window.open(`file://${fixtures}/pages/window-opener.html`, '', 'show=no')
})
})
describe('window.postMessage', () => {
it('throws an exception when the targetOrigin cannot be converted to a string', () => {
const b = window.open('')
expect(() => {
b.postMessage('test', { toString: null })
}).to.throw('Cannot convert object to primitive value')
b.close()
})
})
describe('window.opener.postMessage', () => {
it('sets source and origin correctly', (done) => {
let b = null
listener = (event) => {
window.removeEventListener('message', listener)
b.close()
expect(event.source).to.equal(b)
expect(event.origin).to.equal('file://')
done()
}
window.addEventListener('message', listener)
b = window.open(`file://${fixtures}/pages/window-opener-postMessage.html`, '', 'show=no')
})
it('supports windows opened from a <webview>', (done) => {
const webview = new WebView()
webview.addEventListener('console-message', (e) => {
webview.remove()
expect(e.message).to.equal('message')
done()
})
webview.allowpopups = true
webview.src = url.format({
pathname: `${fixtures}/pages/webview-opener-postMessage.html`,
protocol: 'file',
query: {
p: `${fixtures}/pages/window-opener-postMessage.html`
},
slashes: true
})
document.body.appendChild(webview)
})
describe('targetOrigin argument', () => {
let serverURL
let server
beforeEach((done) => {
server = http.createServer((req, res) => {
res.writeHead(200)
const filePath = path.join(fixtures, 'pages', 'window-opener-targetOrigin.html')
res.end(fs.readFileSync(filePath, 'utf8'))
})
server.listen(0, '127.0.0.1', () => {
serverURL = `http://127.0.0.1:${server.address().port}`
done()
})
})
afterEach(() => {
server.close()
})
it('delivers messages that match the origin', (done) => {
let b = null
listener = (event) => {
window.removeEventListener('message', listener)
b.close()
expect(event.data).to.equal('deliver')
done()
}
window.addEventListener('message', listener)
b = window.open(serverURL, '', 'show=no')
})
})
})
describe('webgl', () => {
before(function () {
if (process.platform === 'win32') {
this.skip()
}
})
it('can be get as context in canvas', () => {
if (process.platform === 'linux') {
// FIXME(alexeykuzmin): Skip the test.
// this.skip()
return
}
const webgl = document.createElement('canvas').getContext('webgl')
expect(webgl).to.not.be.null()
})
})
describe('web workers', () => {
it('Worker can work', (done) => {
const worker = new Worker('../fixtures/workers/worker.js')
const message = 'ping'
worker.onmessage = (event) => {
expect(event.data).to.equal(message)
worker.terminate()
done()
}
worker.postMessage(message)
})
it('Worker has no node integration by default', (done) => {
const worker = new Worker('../fixtures/workers/worker_node.js')
worker.onmessage = (event) => {
expect(event.data).to.equal('undefined undefined undefined undefined')
worker.terminate()
done()
}
})
it('Worker has node integration with nodeIntegrationInWorker', (done) => {
const webview = new WebView()
webview.addEventListener('ipc-message', (e) => {
expect(e.channel).to.equal('object function object function')
webview.remove()
done()
})
webview.src = `file://${fixtures}/pages/worker.html`
webview.setAttribute('webpreferences', 'nodeIntegration, nodeIntegrationInWorker')
document.body.appendChild(webview)
})
// FIXME: disabled during chromium update due to crash in content::WorkerScriptFetchInitiator::CreateScriptLoaderOnIO
xdescribe('SharedWorker', () => {
it('can work', (done) => {
const worker = new SharedWorker('../fixtures/workers/shared_worker.js')
const message = 'ping'
worker.port.onmessage = (event) => {
expect(event.data).to.equal(message)
done()
}
worker.port.postMessage(message)
})
it('has no node integration by default', (done) => {
const worker = new SharedWorker('../fixtures/workers/shared_worker_node.js')
worker.port.onmessage = (event) => {
expect(event.data).to.equal('undefined undefined undefined undefined')
done()
}
})
it('has node integration with nodeIntegrationInWorker', (done) => {
const webview = new WebView()
webview.addEventListener('console-message', (e) => {
console.log(e)
})
webview.addEventListener('ipc-message', (e) => {
expect(e.channel).to.equal('object function object function')
webview.remove()
done()
})
webview.src = `file://${fixtures}/pages/shared_worker.html`
webview.setAttribute('webpreferences', 'nodeIntegration, nodeIntegrationInWorker')
document.body.appendChild(webview)
})
})
})
describe('iframe', () => {
let iframe = null
beforeEach(() => {
iframe = document.createElement('iframe')
})
afterEach(() => {
document.body.removeChild(iframe)
})
it('does not have node integration', (done) => {
iframe.src = `file://${fixtures}/pages/set-global.html`
document.body.appendChild(iframe)
iframe.onload = () => {
expect(iframe.contentWindow.test).to.equal('undefined undefined undefined')
done()
}
})
})
describe('storage', () => {
describe('DOM storage quota increase', () => {
['localStorage', 'sessionStorage'].forEach((storageName) => {
const storage = window[storageName]
it(`allows saving at least 40MiB in ${storageName}`, (done) => {
// Although JavaScript strings use UTF-16, the underlying
// storage provider may encode strings differently, muddling the
// translation between character and byte counts. However,
// a string of 40 * 2^20 characters will require at least 40MiB
// and presumably no more than 80MiB, a size guaranteed to
// to exceed the original 10MiB quota yet stay within the
// new 100MiB quota.
// Note that both the key name and value affect the total size.
const testKeyName = '_electronDOMStorageQuotaIncreasedTest'
const length = 40 * Math.pow(2, 20) - testKeyName.length
storage.setItem(testKeyName, 'X'.repeat(length))
// Wait at least one turn of the event loop to help avoid false positives
// Although not entirely necessary, the previous version of this test case
// failed to detect a real problem (perhaps related to DOM storage data caching)
// wherein calling `getItem` immediately after `setItem` would appear to work
// but then later (e.g. next tick) it would not.
setTimeout(() => {
expect(storage.getItem(testKeyName)).to.have.lengthOf(length)
storage.removeItem(testKeyName)
done()
}, 1)
})
it(`throws when attempting to use more than 128MiB in ${storageName}`, () => {
expect(() => {
const testKeyName = '_electronDOMStorageQuotaStillEnforcedTest'
const length = 128 * Math.pow(2, 20) - testKeyName.length
try {
storage.setItem(testKeyName, 'X'.repeat(length))
} finally {
storage.removeItem(testKeyName)
}
}).to.throw()
})
})
})
it('requesting persitent quota works', (done) => {
navigator.webkitPersistentStorage.requestQuota(1024 * 1024, (grantedBytes) => {
expect(grantedBytes).to.equal(1048576)
done()
})
})
})
describe('websockets', () => {
let wss = null
let server = null
const WebSocketServer = ws.Server
afterEach(() => {
wss.close()
server.close()
})
it('has user agent', (done) => {
server = http.createServer()
server.listen(0, '127.0.0.1', () => {
const port = server.address().port
wss = new WebSocketServer({ server: server })
wss.on('error', done)
wss.on('connection', (ws, upgradeReq) => {
if (upgradeReq.headers['user-agent']) {
done()
} else {
done('user agent is empty')
}
})
const socket = new WebSocket(`ws://127.0.0.1:${port}`)
})
})
})
describe('Promise', () => {
it('resolves correctly in Node.js calls', (done) => {
class XElement extends HTMLElement {}
customElements.define('x-element', XElement)
setImmediate(() => {
let called = false
Promise.resolve().then(() => {
done(called ? void 0 : new Error('wrong sequence'))
})
document.createElement('x-element')
called = true
})
})
it('resolves correctly in Electron calls', (done) => {
class YElement extends HTMLElement {}
customElements.define('y-element', YElement)
ipcRenderer.invoke('ping').then(() => {
let called = false
Promise.resolve().then(() => {
done(called ? void 0 : new Error('wrong sequence'))
})
document.createElement('y-element')
called = true
})
})
})
describe('fetch', () => {
it('does not crash', (done) => {
const server = http.createServer((req, res) => {
res.end('test')
server.close()
})
server.listen(0, '127.0.0.1', () => {
const port = server.address().port
fetch(`http://127.0.0.1:${port}`).then((res) => res.body.getReader())
.then((reader) => {
reader.read().then((r) => {
reader.cancel()
done()
})
}).catch((e) => done(e))
})
})
})
describe('window.alert(message, title)', () => {
it('throws an exception when the arguments cannot be converted to strings', () => {
expect(() => {
window.alert({ toString: null })
}).to.throw('Cannot convert object to primitive value')
})
})
describe('window.confirm(message, title)', () => {
it('throws an exception when the arguments cannot be converted to strings', () => {
expect(() => {
window.confirm({ toString: null }, 'title')
}).to.throw('Cannot convert object to primitive value')
})
})
describe('window.history', () => {
describe('window.history.go(offset)', () => {
it('throws an exception when the argumnet cannot be converted to a string', () => {
expect(() => {
window.history.go({ toString: null })
}).to.throw('Cannot convert object to primitive value')
})
})
})
// TODO(nornagon): this is broken on CI, it triggers:
// [FATAL:speech_synthesis.mojom-shared.h(237)] The outgoing message will
// trigger VALIDATION_ERROR_UNEXPECTED_NULL_POINTER at the receiving side
// (null text in SpeechSynthesisUtterance struct).
describe.skip('SpeechSynthesis', () => {
before(function () {
if (!features.isTtsEnabled()) {
this.skip()
}
})
it('should emit lifecycle events', async () => {
const sentence = `long sentence which will take at least a few seconds to
utter so that it's possible to pause and resume before the end`
const utter = new SpeechSynthesisUtterance(sentence)
// Create a dummy utterence so that speech synthesis state
// is initialized for later calls.
speechSynthesis.speak(new SpeechSynthesisUtterance())
speechSynthesis.cancel()
speechSynthesis.speak(utter)
// paused state after speak()
expect(speechSynthesis.paused).to.be.false()
await new Promise((resolve) => { utter.onstart = resolve })
// paused state after start event
expect(speechSynthesis.paused).to.be.false()
speechSynthesis.pause()
// paused state changes async, right before the pause event
expect(speechSynthesis.paused).to.be.false()
await new Promise((resolve) => { utter.onpause = resolve })
expect(speechSynthesis.paused).to.be.true()
speechSynthesis.resume()
await new Promise((resolve) => { utter.onresume = resolve })
// paused state after resume event
expect(speechSynthesis.paused).to.be.false()
await new Promise((resolve) => { utter.onend = resolve })
})
})
})
describe('console functions', () => {
it('should exist', () => {
expect(console.log, 'log').to.be.a('function')
expect(console.error, 'error').to.be.a('function')
expect(console.warn, 'warn').to.be.a('function')
expect(console.info, 'info').to.be.a('function')
expect(console.debug, 'debug').to.be.a('function')
expect(console.trace, 'trace').to.be.a('function')
expect(console.time, 'time').to.be.a('function')
expect(console.timeEnd, 'timeEnd').to.be.a('function')
})
})