-
Notifications
You must be signed in to change notification settings - Fork 1
/
index.js
249 lines (205 loc) · 7.45 KB
/
index.js
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
'use strict'
const wd = require('wd')
const FirefoxProfile = require('firefox-profile')
const uuid = require('uuid').v4
const Provider = require('browser-provider')
const Browser = require('abstract-browser')
const sauceBrowsers = require('airtap-sauce-browsers').callback
const sauceConnectLauncher = require('sauce-connect-launcher')
const transient = require('transient-error')
const buildNumber = require('build-number')
const debug = require('debug')('airtap-sauce')
const debugTunnel = require('debug')('airtap-sauce:sc')
const fs = require('fs')
const kHostname = Symbol('kHostname')
const kPort = Symbol('kPort')
const kUsername = Symbol('kUsername')
const kAccessKey = Symbol('kAccessKey')
const kTunnelOptions = Symbol('kTunnelOptions')
const kTunnelIdentifier = Symbol('kTunnelIdentifier')
const kProvider = Symbol('kProvider')
const kWebdriver = Symbol('kWebdriver')
const kInitWebdriver = Symbol('kInitWebdriver')
const kPing = Symbol('kPing')
const kPingTimer = Symbol('kPingTimer')
class SauceProvider extends Provider {
constructor (options) {
super(options)
this[kHostname] = this.options.hostname || 'ondemand.saucelabs.com'
this[kPort] = parseInt(this.options.port || 80, 10)
this[kUsername] = process.env.SAUCE_USERNAME || this.options.username
this[kAccessKey] = process.env.SAUCE_ACCESS_KEY || this.options.key
this[kTunnelOptions] = this.options.tunnel || {}
this[kTunnelIdentifier] = null
}
_manifests (callback) {
sauceBrowsers(callback)
}
_browser (manifest, target) {
return new SauceBrowser(this, manifest, target)
}
_tunnel ({ domains }, callback) {
// If the Travis Sauce Connect addon is running, use that. Is there a
// better way to detect this (e.g. some environment variable)?
if (process.env.TRAVIS_JOB_NUMBER && fs.existsSync('/home/travis/sauce-connect.log')) {
debug('using Travis Sauce Connect')
this[kTunnelIdentifier] = process.env.TRAVIS_JOB_NUMBER
return process.nextTick(callback)
} else if (!this[kUsername] || !this[kAccessKey]) {
this[kTunnelIdentifier] = null
return process.nextTick(callback, new NoCredentialsError())
}
// Required for concurrent tunnels
this[kTunnelIdentifier] = 'airtap-sauce-' + uuid()
const tunnelOptions = {
// Disable SSL bumping by default (slower, but less issues)
noSslBumpDomains: 'all',
// Only route the specified domains through the tunnel
tunnelDomains: domains,
// Enable retries
connectRetries: 3,
connectRetryTimeout: 5e3,
downloadRetries: 3,
downloadRetryTimeout: 5e3,
// TODO: camelCase
...this[kTunnelOptions],
username: this[kUsername],
accessKey: this[kAccessKey],
tunnelIdentifier: this[kTunnelIdentifier],
logger: debugTunnel
}
sauceConnectLauncher(tunnelOptions, (err, sauceConnect) => {
if (err) return callback(err)
debug('sauce connect tunnel: %s', this[kTunnelIdentifier])
callback(null, sauceConnect)
})
}
}
module.exports = SauceProvider
class SauceBrowser extends Browser {
constructor (provider, manifest, target) {
super(manifest, target)
this[kWebdriver] = null
this[kPingTimer] = null
this[kProvider] = provider
}
_open (cb) {
debug('manifest: %O', this.manifest)
const hostname = this[kProvider][kHostname]
const port = this[kProvider][kPort]
const username = this[kProvider][kUsername]
const key = this[kProvider][kAccessKey]
if (!username || !key) {
return process.nextTick(cb, new NoCredentialsError())
}
const webdriver = wd.remote(hostname, port, username, key)
// Has no timeout by default, which can put us in limbo.
webdriver.configureHttp({ timeout: 60e3 })
const options = this.manifest.options
const isAppium = this.manifest.automationBackend === 'appium'
const type = isAppium ? 'appium' : 'legacy'
const caps = { ...this.manifest.capabilities[type], ...options.capabilities }
if (Object.keys(caps).length === 0) {
return process.nextTick(cb, new Error('Capabilities are required'))
}
const testName = options.name || this.manifest.name
const build = buildNumber() || process.env.GITHUB_RUN_ID
const tunnelIdentifier = this[kProvider][kTunnelIdentifier]
const appiumVersion = isAppium && process.env.SAUCE_APPIUM_VERSION
if (testName) caps.name = testName
if (build) caps.build = build
if (tunnelIdentifier) caps.tunnelIdentifier = tunnelIdentifier
if (appiumVersion) caps.appiumVersion = appiumVersion
if (this.manifest.name === 'firefox' && options.profile) {
const fp = new FirefoxProfile()
const profile = options.profile
const extensions = profile.extensions
for (const preference in profile) {
if (preference !== 'extensions') {
fp.setPreference(preference, profile[preference])
}
}
fp.addExtensions(extensions || [], () => {
fp.encoded((zippedProfile) => {
caps.firefox_profile = zippedProfile
this[kInitWebdriver](webdriver, caps, cb)
})
})
} else {
this[kInitWebdriver](webdriver, caps, cb)
}
}
// TODO: use cleanError() and don't retry on all errors
[kInitWebdriver] (webdriver, caps, cb) {
webdriver.init(caps, (err, sessionId, actualCaps) => {
if (err) {
if (err.data) {
err.message += ': ' + err.data.split('\n').slice(0, 1)
}
return cb(transient(err))
}
debug('webdriver session: %s', sessionId)
debug('actual capabilities: %O', actualCaps)
webdriver.get(this.target.url, (err) => {
if (err) {
return webdriver.quit(() => {
cb(transient(err))
})
}
// Don't set until we have a session (that we can quit)
this[kWebdriver] = webdriver
this[kPing]()
cb()
})
})
}
// Periodically send a dummy command to prevent timing out and to
// catch Sauce Labs operational issues as well as user cancelation.
[kPing] () {
const timer = this[kPingTimer] = setInterval(() => {
this[kWebdriver].url((err) => {
if (this[kPingTimer] !== timer) return
if (err) this.emit('error', cleanError(err))
})
}, 30e3)
}
_setStatus (ok, callback) {
if (!this[kWebdriver]) return callback()
this[kWebdriver].sauceJobStatus(ok, (err) => {
if (err) debug('setting job status failed: %O', cleanError(err))
callback()
})
}
_close (cb) {
clearInterval(this[kPingTimer])
this[kPingTimer] = null
if (this[kWebdriver]) {
const webdriver = this[kWebdriver]
this[kWebdriver] = null
webdriver.quit(cb)
} else {
cb()
}
}
}
function cleanError (err) {
// The `wd` module doesn't parse these error responses, it expects JSON.
if (/not json response/i.test(err.message) && typeof err.data === 'string') {
if (/has already finished/i.test(err.data)) {
return new Error('Sauce Labs test finished prematurely or was canceled by user')
} else if (/internal server error/i.test(err.data)) {
// Retry on Sauce Labs operational issues
return transient(new Error(err.data))
} else if (err.data) {
return new Error(err.data)
}
}
return err
}
class NoCredentialsError extends Error {
constructor () {
super('Sauce Labs credentials are required')
Object.defineProperty(this, 'name', { value: 'NoCredentialsError' })
Object.defineProperty(this, 'expected', { value: true })
}
}