system-testing 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json ADDED
@@ -0,0 +1,36 @@
1
+ {
2
+ "name": "system-testing",
3
+ "version": "1.0.0",
4
+ "description": "System testing with Selenium and browsers.",
5
+ "keywords": [
6
+ "system",
7
+ "testing",
8
+ "selenium",
9
+ "chrome"
10
+ ],
11
+ "homepage": "https://github.com/kaspernj/system-testing#readme",
12
+ "bugs": {
13
+ "url": "https://github.com/kaspernj/system-testing/issues"
14
+ },
15
+ "repository": {
16
+ "type": "git",
17
+ "url": "git+https://github.com/kaspernj/system-testing.git"
18
+ },
19
+ "license": "ISC",
20
+ "author": "kasper@diestoeckels.de",
21
+ "type": "module",
22
+ "main": "src/index.js",
23
+ "scripts": {
24
+ "test": "echo \"Error: no test specified\" && exit 1"
25
+ },
26
+ "dependencies": {
27
+ "diggerize": "^1.0.9",
28
+ "htmlfy": "^0.8.1",
29
+ "mime": "^4.0.7",
30
+ "moment": "^2.30.1",
31
+ "ws": "^8.18.3"
32
+ },
33
+ "peerDependencies": {
34
+ "selenium-webdriver": "^4.34.0"
35
+ }
36
+ }
@@ -0,0 +1,150 @@
1
+ import {digg} from "diggerize"
2
+ import EventEmitter from "events"
3
+
4
+ import SystemTestCommunicator from "./system-test-communicator"
5
+
6
+ const shared = {}
7
+
8
+ export default class SystemTestBrowserHelper {
9
+ static current() {
10
+ if (!shared.systemTestBrowserHelper) {
11
+ throw new Error("No current SystemTestBrowserHelper set")
12
+ }
13
+
14
+ return shared.systemTestBrowserHelper
15
+ }
16
+
17
+ constructor() {
18
+ this.communicator = new SystemTestCommunicator({parent: this, onCommand: this.onCommand})
19
+ this._enabled = false
20
+ this.events = new EventEmitter()
21
+
22
+ shared.systemTestBrowserHelper = this
23
+ }
24
+
25
+ connectOnError() {
26
+ window.addEventListener("error", (event) => {
27
+ this.handleError({
28
+ type: "error",
29
+ error: event.error,
30
+ errorClass: event.error?.name,
31
+ file: event.filename,
32
+ line: event.lineno,
33
+ message: event.message || "Unknown error",
34
+ url: window.location.href
35
+ })
36
+ })
37
+ }
38
+
39
+ connectUnhandledRejection() {
40
+ window.addEventListener("unhandledrejection", (event) => {
41
+ this.handleError({
42
+ type: "unhandledrejection",
43
+ error: event.reason,
44
+ errorClass: "UnhandledRejection",
45
+ file: null,
46
+ line: null,
47
+ message: event.reason.message || "Unhandled promise rejection",
48
+ url: window.location.href
49
+ })
50
+ })
51
+ }
52
+
53
+ handleError(data) {
54
+ let backtrace
55
+
56
+ if (data.error && data.error.stack) {
57
+ backtrace = data.error.stack
58
+ } else if (data.file) {
59
+ backtrace = [`${data.file}:${data.line}`]
60
+ }
61
+
62
+ data.backtrace = backtrace
63
+
64
+ this.communicator.sendCommand(data)
65
+ }
66
+
67
+ connectWebSocket() {
68
+ this.ws = new WebSocket("ws://localhost:1985") // eslint-disable-line no-undef
69
+
70
+ this.communicator.ws = this.ws
71
+ this.ws.addEventListener("error", digg(this, "communicator", "onError"))
72
+ this.ws.addEventListener("open", digg(this, "communicator", "onOpen"))
73
+ this.ws.addEventListener("message", (event) => this.communicator.onMessage(event.data))
74
+ }
75
+
76
+ enableOnBrowser() {
77
+ this._enabled = true
78
+ this.connectWebSocket()
79
+ this.connectOnError()
80
+ this.connectUnhandledRejection()
81
+ this.overrideConsoleLog()
82
+ }
83
+
84
+ getEnabled = () => this._enabled
85
+ getEvents = () => this.events
86
+
87
+ fakeConsoleError = (...args) => {
88
+ this.communicator.sendCommand({type: "console.error", value: this.consoleLogMessage(args)})
89
+
90
+ return this.originalConsoleError(...args)
91
+ }
92
+
93
+ fakeConsoleLog = (...args) => {
94
+ this.communicator.sendCommand({type: "console.log", value: this.consoleLogMessage(args)})
95
+
96
+ return this.originalConsoleLog(...args)
97
+ }
98
+
99
+ consoleLogMessage(arg) {
100
+ if (Array.isArray(arg)) {
101
+ const result = []
102
+
103
+ for (const value of arg) {
104
+ result.push(this.consoleLogMessage(value))
105
+ }
106
+
107
+ return result
108
+ } else if (Object.prototype.toString.call(arg) === '[object Object]') {
109
+ const result = {}
110
+
111
+ for (const key in arg) {
112
+ result[key] = this.consoleLogMessage(arg[key])
113
+ }
114
+
115
+ return result
116
+ } else if (typeof arg == "object") {
117
+ return `[object ${arg?.constructor?.name}]`
118
+ } else {
119
+ return arg
120
+ }
121
+ }
122
+
123
+ onCommand = async ({data}) => {
124
+ if (data.type == "initialize") {
125
+ this.events.emit("initialize")
126
+
127
+ return {result: "initialized"}
128
+ } else if (data.type == "visit") {
129
+ this.events.emit("navigate", {path: data.path})
130
+ } else {
131
+ throw new Error(`Unknown command type for SystemTestBrowserHelper: ${data.type}`)
132
+ }
133
+ }
134
+
135
+ overrideConsoleLog() {
136
+ if (this.originalConsoleError || this.originalConsoleLog) {
137
+ throw new Error("Console methods has already been overridden!")
138
+ }
139
+
140
+ this.originalConsoleError = console.error
141
+ this.originalConsoleLog = console.log
142
+
143
+ console.error = this.fakeConsoleError
144
+ console.log = this.fakeConsoleLog
145
+ }
146
+
147
+ async sendQuery(sql) {
148
+ return await this.communicator.sendCommand({type: "query", sql})
149
+ }
150
+ }
@@ -0,0 +1,79 @@
1
+ export default class SystemTestCommunicator {
2
+ constructor({onCommand, parent}) {
3
+ this.onCommand = onCommand
4
+ this.parent = parent
5
+ this._sendQueueCount = 0
6
+ this._sendQueue = []
7
+ this._responses = {}
8
+ }
9
+
10
+ flushSendQueue() {
11
+ while (this._sendQueue.length !== 0) {
12
+ const data = this._sendQueue.shift()
13
+
14
+ this.ws.send(JSON.stringify(data))
15
+ }
16
+ }
17
+
18
+ onError = (error) => {
19
+ console.error("onWebSocketClientError", error)
20
+ }
21
+
22
+ onMessage = async (rawData) => {
23
+ const data = JSON.parse(rawData)
24
+
25
+ if (data.isTrusted) {
26
+ // Ignore
27
+ } else if (data.type == "command") {
28
+ try {
29
+ const result = await this.onCommand({data: data.data})
30
+
31
+ this.respond(data.id, {result})
32
+ } catch (error) {
33
+ this.respond(data.id, {error: error.message})
34
+ }
35
+ } else if (data.type == "response") {
36
+ const response = this._responses[data.id]
37
+
38
+ if (!response) {
39
+ throw new Error(`No such response: ${data.id}`)
40
+ }
41
+
42
+ delete this._responses[data.id]
43
+
44
+ if (data.data.error) {
45
+ response.error(data.data.error)
46
+ } else {
47
+ response.resolve(data.data.result)
48
+ }
49
+ } else {
50
+ throw new Error(`Unknown type for SystemTestCommunicator: ${data.type}: ${JSON.stringify(data)}`)
51
+ }
52
+ }
53
+
54
+ onOpen = () => {
55
+ this.flushSendQueue()
56
+ }
57
+
58
+ send(data) {
59
+ this._sendQueue.push(data)
60
+
61
+ if (this.ws?.readyState == 1) {
62
+ this.flushSendQueue()
63
+ }
64
+ }
65
+
66
+ sendCommand(data) {
67
+ return new Promise((resolve, error) => {
68
+ const id = this._sendQueueCount
69
+
70
+ this._sendQueueCount += 1
71
+ this._responses[id] = {resolve, error}
72
+ this.send({type: "command", id, data})
73
+ })
74
+ }
75
+
76
+ respond(id, data) {
77
+ this.send({type: "response", id, data})
78
+ }
79
+ }
@@ -0,0 +1,54 @@
1
+ import fs from "node:fs/promises"
2
+ import http from "node:http"
3
+ import mime from "mime"
4
+ import url from "url"
5
+
6
+ export default class SystemTestHttpServer {
7
+ close() {
8
+ this.httpServer.close()
9
+ }
10
+
11
+ onHttpServerRequest = async (request, response) => {
12
+ const parsedUrl = url.parse(request.url)
13
+ let filePath = `${this.basePath}/dist${parsedUrl.pathname}`
14
+
15
+ if (filePath.endsWith("/")) {
16
+ filePath += "index.html"
17
+ }
18
+
19
+ let fileExists
20
+
21
+ try {
22
+ await fs.stat(filePath)
23
+ fileExists = true
24
+ } catch (_error) {
25
+ fileExists = false
26
+ }
27
+
28
+ if (!fileExists) {
29
+ filePath = `${this.basePath}/dist/index.html`
30
+ }
31
+
32
+ const fileContent = await fs.readFile(filePath)
33
+ const mimeType = mime.lookup(filePath)
34
+
35
+ response.statusCode = 200
36
+ response.setHeader("Content-Type", mimeType)
37
+ response.end(fileContent)
38
+ }
39
+
40
+ async start() {
41
+ this.basePath = await fs.realpath(`${__dirname}/../..`) // eslint-disable-line no-undef
42
+
43
+ await this.startHttpServer()
44
+ }
45
+
46
+ startHttpServer() {
47
+ return new Promise((resolve) => {
48
+ this.httpServer = http.createServer(this.onHttpServerRequest)
49
+ this.httpServer.listen(1984, "localhost", () => {
50
+ resolve()
51
+ })
52
+ })
53
+ }
54
+ }
@@ -0,0 +1,334 @@
1
+ import {Builder, By} from "selenium-webdriver"
2
+ import chrome from "selenium-webdriver/chrome"
3
+ import {digg} from "diggerize"
4
+ import fs from "node:fs/promises"
5
+ import logging from "selenium-webdriver/lib/logging"
6
+ import moment from "moment"
7
+ import {prettify} from "htmlfy"
8
+ import SystemTestCommunicator from "./system-test-communicator"
9
+ import SystemTestHttpServer from "./system-test-http-server"
10
+ import {WebSocketServer} from "ws"
11
+
12
+ class ElementNotFoundError extends Error { }
13
+
14
+ export default class SystemTest {
15
+ static current() {
16
+ if (!globalThis.systemTest) {
17
+ globalThis.systemTest = new SystemTest()
18
+ }
19
+
20
+ return globalThis.systemTest
21
+ }
22
+
23
+ static async run(callback) {
24
+ const systemTest = this.current()
25
+
26
+ await systemTest.communicator.sendCommand({type: "initialize"})
27
+ await systemTest.visit("/blank")
28
+ await systemTest.findByTestID("blankText")
29
+
30
+ try {
31
+ await callback(systemTest)
32
+ } catch (error) {
33
+ await systemTest.takeScreenshot()
34
+
35
+ throw error
36
+ }
37
+ }
38
+
39
+ constructor() {
40
+ this.communicator = new SystemTestCommunicator({onCommand: this.onCommand})
41
+ this._responses = {}
42
+ this._sendCount = 0
43
+ }
44
+
45
+ async all(selector, args = {}) {
46
+ const {visible = true} = args
47
+ const elements = await this.driver.findElements(By.css(selector))
48
+ const activeElements = []
49
+
50
+ for (const element of elements) {
51
+ let keep = true
52
+
53
+ if (visible === true || visible === false) {
54
+ const isDisplayed = await element.isDisplayed()
55
+
56
+ if (visible && !isDisplayed) keep = false
57
+ if (!visible && isDisplayed) keep = false
58
+ }
59
+
60
+ if (keep) activeElements.push(element)
61
+ }
62
+
63
+ return activeElements
64
+ }
65
+
66
+ // Clicks an element that has children which fills out the element and would otherwise have caused a ElementClickInterceptedError
67
+ async click(element) {
68
+ const actions = this.driver.actions({async: true})
69
+
70
+ await actions.move({origin: element}).click().perform()
71
+ }
72
+
73
+ async find(selector, args = {}) {
74
+ const elements = await this.all(selector, args)
75
+
76
+ if (elements.length > 1) {
77
+ throw new Error(`More than 1 elements (${elements.length}) was found by CSS: ${selector}`)
78
+ }
79
+
80
+ if (!elements[0]) {
81
+ throw new ElementNotFoundError(`Element couldn't be found by CSS: ${selector}`)
82
+ }
83
+
84
+ return elements[0]
85
+ }
86
+
87
+ findByTestID = async (testID, args) => await this.find(`[data-testid='${testID}']`, args)
88
+
89
+ async findNoWait(selector) {
90
+ await this.driverSetTimeouts(0)
91
+
92
+ try {
93
+ return await this.find(selector)
94
+ } finally {
95
+ await this.restoreTimeouts()
96
+ }
97
+ }
98
+
99
+ async getBrowserLogs() {
100
+ const entries = await this.driver.manage().logs().get(logging.Type.BROWSER)
101
+ const browserLogs = []
102
+
103
+ for (const entry of entries) {
104
+ const messageMatch = entry.message.match(/^(.+) (\d+):(\d+) (.+)$/)
105
+ let message
106
+
107
+ if (messageMatch) {
108
+ message = messageMatch[4]
109
+ } else {
110
+ message = entry.message
111
+ }
112
+
113
+ browserLogs.push(`${entry.level.name}: ${message}`)
114
+ }
115
+
116
+ return browserLogs
117
+ }
118
+
119
+ async expectNoElement(selector) {
120
+ let found = false
121
+
122
+ try {
123
+ await this.findNoWait(selector)
124
+ found = true
125
+ } catch (error) {
126
+ if (!error.message.startsWith("Element couldn't be found by CSS:")) {
127
+ throw error
128
+ }
129
+ }
130
+
131
+ if (found) {
132
+ throw new Error(`Expected not to find: ${selector}`)
133
+ }
134
+ }
135
+
136
+ async notificationMessages() {
137
+ const notificationMessageElements = await this.all("[data-class='notification-message']")
138
+ const notificationMessageTexts = []
139
+
140
+ for (const notificationMessageElement of notificationMessageElements) {
141
+ const text = await notificationMessageElement.getText()
142
+
143
+ notificationMessageTexts.push(text)
144
+ }
145
+
146
+ return notificationMessageTexts
147
+ }
148
+
149
+ isStarted() {
150
+ return this._started
151
+ }
152
+
153
+ async getHTML() {
154
+ return await this.driver.getPageSource()
155
+ }
156
+
157
+ async start() {
158
+ if (process.env.SYSTEM_TEST_HOST == "expo-dev-server") {
159
+ this.currentUrl = "http://localhost:8081"
160
+ } else if (process.env.SYSTEM_TEST_HOST == "dist") {
161
+ this.currentUrl = "http://localhost:1984"
162
+ this.systemTestHttpServer = new SystemTestHttpServer()
163
+
164
+ await this.systemTestHttpServer.start()
165
+ } else {
166
+ throw new Error("Please set SYSTEM_TEST_HOST to 'expo-dev-server' or 'dist'")
167
+ }
168
+
169
+ const options = new chrome.Options()
170
+
171
+ options.addArguments("--disable-dev-shm-usage")
172
+ options.addArguments("--disable-gpu")
173
+ options.addArguments("--headless=new")
174
+ options.addArguments("--no-sandbox")
175
+ options.addArguments("--window-size=1920,1080")
176
+
177
+ this.driver = new Builder()
178
+ .forBrowser("chrome")
179
+ .setChromeOptions(options)
180
+ .setCapability("goog:loggingPrefs", {browser: "ALL"})
181
+ .build()
182
+
183
+ this.setTimeouts(4000)
184
+
185
+ // Web socket server to communicate with browser
186
+ await this.startWebSocketServer()
187
+
188
+ // Visit the root page and wait for Expo to be loaded and the app to appear
189
+ await this.driverVisit("/?systemTest=true")
190
+ await this.find("body > #root")
191
+ await this.find("[data-component='flash-notifications-container']", {visible: null})
192
+
193
+ // Wait for client to connect
194
+ await this.waitForClientWebSocket()
195
+
196
+ this._started = true
197
+ }
198
+
199
+ async restoreTimeouts() {
200
+ if (!this._timeouts) {
201
+ throw new Error("Timeouts haven't previously been set")
202
+ }
203
+
204
+ await this.driverSetTimeouts(this._timeouts)
205
+ }
206
+
207
+ async driverSetTimeouts(newTimeout) {
208
+ await this.driver.manage().setTimeouts({implicit: newTimeout})
209
+ }
210
+
211
+ async setTimeouts(newTimeout) {
212
+ this._timeouts = newTimeout
213
+ await this.restoreTimeouts()
214
+ }
215
+
216
+ waitForClientWebSocket() {
217
+ return new Promise((resolve) => {
218
+ if (this.ws) {
219
+ resolve()
220
+ }
221
+
222
+ this.waitForClientWebSocketPromiseResolve = resolve
223
+ })
224
+ }
225
+
226
+ startWebSocketServer() {
227
+ this.wss = new WebSocketServer({port: 1985})
228
+ this.wss.on("connection", this.onWebSocketConnection)
229
+ this.wss.on("close", this.onWebSocketClose)
230
+ }
231
+
232
+ onCommand = async ({data}) => {
233
+ const type = data.type
234
+ let result
235
+
236
+ if (type == "console.error") {
237
+ const errorMessage = data.value[0]
238
+ let showMessage = true
239
+
240
+ if (errorMessage.includes("Minified React error #419")) {
241
+ showMessage = false
242
+ }
243
+
244
+ if (showMessage) {
245
+ console.error("Browser error", ...data.value)
246
+ }
247
+ } else if (type == "console.log") {
248
+ console.log("Browser log", ...data.value)
249
+ } else if (type == "error" || data.type == "unhandledrejection") {
250
+ this.handleError(data)
251
+ } else if (type == "query") {
252
+ await Configuration.current().getDatabasePool().withConnection(async (db) => {
253
+ result = await db.query(data.sql)
254
+ })
255
+ } else {
256
+ console.error(`onWebSocketClientMessage unknown data (type ${type})`, data)
257
+ }
258
+
259
+ return result
260
+ }
261
+
262
+ onWebSocketConnection = async (ws) => {
263
+ this.ws = ws
264
+ this.communicator.ws = ws
265
+ this.communicator.onOpen()
266
+ this.ws.on("error", digg(this, "communicator", "onError"))
267
+ this.ws.on("message", digg(this, "communicator", "onMessage"))
268
+
269
+ if (this.waitForClientWebSocketPromiseResolve) {
270
+ this.waitForClientWebSocketPromiseResolve()
271
+ delete this.waitForClientWebSocketPromiseResolve
272
+ }
273
+ }
274
+
275
+ onWebSocketClose = () => {
276
+ this.ws = null
277
+ this.communicator.ws = null
278
+ }
279
+
280
+ handleError(data) {
281
+ if (data.message.includes("Minified React error #419")) {
282
+ // Ignore this error message
283
+ return
284
+ }
285
+
286
+ setTimeout(() => { // eslint-disable-line no-undef
287
+ const error = new Error(`Browser error: ${data.message}`)
288
+
289
+ if (data.trace) {
290
+ const errorTrace = error.trace
291
+
292
+ error.trace = `${data.trace}${errorTrace}`
293
+ }
294
+
295
+ throw error
296
+ }, 0)
297
+ }
298
+
299
+ async stop() {
300
+ this.systemTestHttpServer?.close()
301
+ this.wss?.close()
302
+ await this.driver.quit()
303
+ }
304
+
305
+ async driverVisit(path) {
306
+ const url = `${this.currentUrl}${path}`
307
+
308
+ await this.driver.get(url)
309
+ }
310
+
311
+ async takeScreenshot() {
312
+ const basePath = await fs.realpath(`${__dirname}/../..`) // eslint-disable-line no-undef
313
+ const path = `${basePath}/tmp/screenshots`
314
+
315
+ await fs.mkdir(path, {recursive: true})
316
+
317
+ const imageContent = await this.driver.takeScreenshot()
318
+ const now = new Date()
319
+ const screenshotPath = `${path}/${moment(now).format("YYYY-MM-DD-HH-MM-SS")}.png`
320
+ const htmlPath = `${path}/${moment(now).format("YYYY-MM-DD-HH-MM-SS")}.html`
321
+ const html = await this.getHTML()
322
+ const htmlPretty = prettify(html)
323
+
324
+ await fs.writeFile(screenshotPath, imageContent, "base64")
325
+ await fs.writeFile(htmlPath, htmlPretty)
326
+
327
+ console.log("Screenshot:", screenshotPath)
328
+ console.log("HTML:", htmlPath)
329
+ }
330
+
331
+ async visit(path) {
332
+ await this.communicator.sendCommand({type: "visit", path})
333
+ }
334
+ }