@wdio/devtools-script 0.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/index.html ADDED
@@ -0,0 +1 @@
1
+ <script type="module" src="./src/index.ts"></script>
package/package.json ADDED
@@ -0,0 +1,23 @@
1
+ {
2
+ "name": "@wdio/devtools-script",
3
+ "version": "0.0.0",
4
+ "description": "Script to be injected into a page to trace the page",
5
+ "author": "Christian Bromann <mail@bromann.dev>",
6
+ "type": "module",
7
+ "types": "./types.d.ts",
8
+ "exports": "./dist/script.js",
9
+ "typeScriptVersion": "^5.0.0",
10
+ "dependencies": {
11
+ "htm": "^3.1.1",
12
+ "parse5": "^8.0.0",
13
+ "preact": "^10.27.1",
14
+ "vite-plugin-singlefile": "^2.3.0"
15
+ },
16
+ "license": "MIT",
17
+ "scripts": {
18
+ "dev": "vite",
19
+ "build": "tsc && vite build",
20
+ "preview": "vite preview",
21
+ "test": "eslint ."
22
+ }
23
+ }
@@ -0,0 +1,47 @@
1
+ import { getLogs, clearLogs } from './logger.js'
2
+ import { ConsoleLogCollector } from './collectors/consoleLogs.js'
3
+
4
+ class DataCollector {
5
+ #metadata = {
6
+ url: window.location.href,
7
+ viewport: window.visualViewport!
8
+ }
9
+ #errors: string[] = []
10
+ #mutations: TraceMutation[] = []
11
+ #consoleLogs = new ConsoleLogCollector()
12
+
13
+ captureError (err: Error) {
14
+ const error = err.stack || err.message
15
+ this.#errors.push(error)
16
+ }
17
+
18
+ captureMutation (mutations: TraceMutation[]) {
19
+ this.#mutations.push(...mutations)
20
+ }
21
+
22
+ reset () {
23
+ this.#errors = []
24
+ this.#mutations = []
25
+ this.#consoleLogs.clear()
26
+ clearLogs()
27
+ }
28
+
29
+ getMetadata () {
30
+ return this.#metadata
31
+ }
32
+
33
+ getTraceData () {
34
+ const data = {
35
+ errors: this.#errors,
36
+ mutations: this.#mutations,
37
+ consoleLogs: this.#consoleLogs.getArtifacts(),
38
+ traceLogs: getLogs(),
39
+ metadata: this.getMetadata(),
40
+ } as const
41
+ this.reset()
42
+ return data
43
+ }
44
+ }
45
+
46
+ export type DataCollectorType = DataCollector
47
+ export const collector = window.wdioTraceCollector = new DataCollector()
@@ -0,0 +1,4 @@
1
+ export abstract class Collector<Artifact> {
2
+ abstract getArtifacts(): Artifact[]
3
+ abstract clear(): void
4
+ }
@@ -0,0 +1,35 @@
1
+ import type { Collector } from './collector.js'
2
+
3
+ const consoleMethods = ['log', 'info', 'warn', 'error'] as const
4
+ export interface ConsoleLogs {
5
+ type: 'log' | 'info' | 'warn' | 'error'
6
+ args: any[]
7
+ timestamp: number
8
+ }
9
+
10
+ export class ConsoleLogCollector implements Collector<ConsoleLogs> {
11
+ #logs: ConsoleLogs[] = []
12
+ constructor () {
13
+ consoleMethods.forEach(this.#consolePatch.bind(this))
14
+ }
15
+
16
+ getArtifacts () {
17
+ return this.#logs
18
+ }
19
+
20
+ clear(): void {
21
+ this.#logs = []
22
+ }
23
+
24
+ #consolePatch (type: (typeof consoleMethods)[number]) {
25
+ const orig = console[type]
26
+ console[type] = (...args) => {
27
+ this.#logs.push({
28
+ timestamp: Date.now(),
29
+ type,
30
+ args
31
+ })
32
+ return orig(...args)
33
+ }
34
+ }
35
+ }
package/src/index.ts ADDED
@@ -0,0 +1,66 @@
1
+ import { waitForBody, parseFragment, parseDocument, getRef, assignRef } from './utils.js'
2
+ import { log } from './logger.js'
3
+ import { collector } from './collector.js'
4
+
5
+ try {
6
+ log('waiting for body to render')
7
+ await waitForBody()
8
+ log('body rendered')
9
+
10
+ assignRef(document.documentElement)
11
+ log('applied wdio ref ids')
12
+
13
+ const timestamp = Date.now()
14
+ collector.captureMutation([{
15
+ type: 'childList',
16
+ url: document.location.href,
17
+ timestamp,
18
+ addedNodes: [parseDocument(document.documentElement)],
19
+ removedNodes: []
20
+ }])
21
+ log('added initial page structure')
22
+
23
+ const config = { attributes: true, childList: true, subtree: true }
24
+ const observer = new MutationObserver((ml) => {
25
+ const timestamp = Date.now()
26
+ const mutationList = ml.filter((m) => m.attributeName !== 'data-wdio-ref')
27
+
28
+ log(`observed ${mutationList.length} mutations`)
29
+ try {
30
+ collector.captureMutation(mutationList.map(({ target: t, addedNodes: an, removedNodes: rn, type, attributeName, attributeNamespace, previousSibling: ps, nextSibling: ns, oldValue }) => {
31
+ const addedNodes = Array.from(an).map((node) => {
32
+ assignRef(node as Element)
33
+ return parseFragment(node as Element)
34
+ })
35
+
36
+ const removedNodes = Array.from(rn).map((node) => getRef(node))
37
+ const target = getRef(t)
38
+ const previousSibling = ps ? getRef(ps) : null
39
+ const nextSibling = ns ? getRef(ns) : null
40
+
41
+ let attributeValue: string | undefined
42
+ if (type === 'attributes') {
43
+ attributeValue = (t as Element).getAttribute(attributeName!) || ''
44
+ }
45
+ let newTextContent: string | undefined
46
+ if (type === 'characterData') {
47
+ newTextContent = (t as Element).textContent || ''
48
+ }
49
+
50
+ log(`added mutation: ${type}`)
51
+ return {
52
+ type, attributeName, attributeNamespace, oldValue, addedNodes, target,
53
+ removedNodes, previousSibling, nextSibling, timestamp, attributeValue,
54
+ newTextContent
55
+ } as TraceMutation
56
+ }))
57
+ } catch (err: any) {
58
+ collector.captureError(err)
59
+ }
60
+ })
61
+ observer.observe(document.body, config)
62
+ } catch (err: any) {
63
+ collector.captureError(err)
64
+ }
65
+
66
+ log('Finished program')
package/src/logger.ts ADDED
@@ -0,0 +1,13 @@
1
+ let logs: string[] = []
2
+
3
+ export function log (...args: any[]) {
4
+ logs.push(args.map((a) => JSON.stringify(a)).join(' '))
5
+ }
6
+
7
+ export function getLogs () {
8
+ return logs
9
+ }
10
+
11
+ export function clearLogs () {
12
+ logs = []
13
+ }
package/src/utils.ts ADDED
@@ -0,0 +1,110 @@
1
+ import { parse, parseFragment as parseFragmentImport, type DefaultTreeAdapterMap } from 'parse5'
2
+ import { h } from 'htm/preact'
3
+ import type { SimplifiedVNode } from '../types.ts'
4
+
5
+ import { log } from './logger.js'
6
+
7
+ export type vFragment = DefaultTreeAdapterMap['documentFragment']
8
+ export type vComment = DefaultTreeAdapterMap['commentNode']
9
+ export type vElement = DefaultTreeAdapterMap['element']
10
+ export type vText = DefaultTreeAdapterMap['textNode']
11
+ export type vChildNode = DefaultTreeAdapterMap['childNode']
12
+
13
+ function createVNode (elem: any) {
14
+ const { type, props } = elem
15
+ return { type, props } as SimplifiedVNode
16
+ }
17
+
18
+ export function parseNode (fragment: vFragment | vComment | vText | vChildNode): SimplifiedVNode | string {
19
+ const props: Record<string, any> = {}
20
+
21
+ if (fragment.nodeName === '#comment') {
22
+ return (fragment as vComment).data
23
+ }
24
+ if (fragment.nodeName === '#text') {
25
+ return (fragment as vText).value
26
+ }
27
+
28
+ const { childNodes, attrs, tagName } = fragment as vElement
29
+ for (const p of (attrs || [])) {
30
+ props[p.name] = p.value
31
+ }
32
+
33
+ try {
34
+ return createVNode(h(tagName, props, ...(childNodes || []).map((cn) => parseNode(cn))) as any)
35
+ } catch (err: any) {
36
+ return createVNode(h('div', { class: 'parseNode' }, err.stack))
37
+ }
38
+ }
39
+
40
+ export function parseDocument (node: HTMLElement) {
41
+ try {
42
+ const fragment = parse(node.outerHTML)
43
+ return parseNode(fragment.childNodes[0])
44
+ } catch (err: any) {
45
+ return createVNode(h('div', { class: 'parseDocument' }, err.stack))
46
+ }
47
+ }
48
+
49
+ export function parseFragment (node: Element) {
50
+ try {
51
+ const fragment = parseFragmentImport(node.outerHTML)
52
+ return parseNode(fragment)
53
+ } catch (err: any) {
54
+ return createVNode(h('div', { class: 'parseFragmentWrapper' }, err.stack))
55
+ }
56
+ }
57
+
58
+ export async function waitForBody () {
59
+ let raf = 0
60
+ let resolve: () => void
61
+ let reject: (err: Error) => void
62
+ const waitForPromise = new Promise<void>((res, rej) => {
63
+ resolve = res
64
+ reject = rej
65
+ })
66
+
67
+ const waitForTimeout = setTimeout(
68
+ () => reject(new Error('Timeout waiting for body')),
69
+ 10000
70
+ )
71
+
72
+ function run () {
73
+ if (!document.body) {
74
+ return
75
+ }
76
+
77
+ resolve()
78
+ }
79
+
80
+ raf = requestAnimationFrame(run)
81
+ await waitForPromise
82
+ cancelAnimationFrame(raf)
83
+ clearTimeout(waitForTimeout)
84
+ }
85
+
86
+ let refId = 0
87
+ /**
88
+ * assign a uid to each element so we can reference it later in the vdom
89
+ */
90
+ export function assignRef (elem: Element) {
91
+ if (typeof elem.querySelectorAll !== 'function') {
92
+ log('assignRef: elem has no querySelectorAll', elem.nodeType || elem.nodeName || elem.textContent || Object.keys(elem))
93
+ return
94
+ }
95
+
96
+ if (!elem.hasAttribute('data-wdio-ref')) {
97
+ elem.setAttribute('data-wdio-ref', `${++refId}`)
98
+ }
99
+
100
+ Array.from(elem.querySelectorAll('*')).forEach(
101
+ (el) => { el.setAttribute('data-wdio-ref', `${++refId}`) })
102
+ }
103
+
104
+ export function getRef (elem: Node) {
105
+ if (!elem || !(elem as Element).getAttribute) {
106
+ return null
107
+ }
108
+ return (elem as Element).getAttribute('data-wdio-ref')
109
+ }
110
+
@@ -0,0 +1,190 @@
1
+ // Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
2
+
3
+ exports[`should be able serialize DOM 1`] = `
4
+ [
5
+ {
6
+ "addedNodes": [
7
+ {
8
+ "props": {
9
+ "children": [
10
+ {
11
+ "props": {
12
+ "children": [
13
+ "
14
+ ",
15
+ {
16
+ "props": {
17
+ "charset": "UTF-8",
18
+ "data-wdio-ref": "3",
19
+ },
20
+ "type": "meta",
21
+ },
22
+ "
23
+ ",
24
+ {
25
+ "props": {
26
+ "data-wdio-ref": "4",
27
+ "href": "/favicon.svg",
28
+ "rel": "icon",
29
+ "type": "image/svg+xml",
30
+ },
31
+ "type": "link",
32
+ },
33
+ "
34
+ ",
35
+ {
36
+ "props": {
37
+ "content": "width=device-width, initial-scale=1.0",
38
+ "data-wdio-ref": "5",
39
+ "name": "viewport",
40
+ },
41
+ "type": "meta",
42
+ },
43
+ "
44
+ ",
45
+ {
46
+ "props": {
47
+ "children": "Vitest Browser Runner",
48
+ "data-wdio-ref": "6",
49
+ },
50
+ "type": "title",
51
+ },
52
+ "
53
+ ",
54
+ {
55
+ "props": {
56
+ "children": "
57
+ html {
58
+ overflow: hidden;
59
+ padding: 0;
60
+ margin: 0;
61
+ }
62
+ body {
63
+ padding: 0;
64
+ margin: 0;
65
+ }
66
+ #vitest-ui {
67
+ width: 100vw;
68
+ height: 100vh;
69
+ border: none;
70
+ }
71
+ ",
72
+ "data-wdio-ref": "7",
73
+ },
74
+ "type": "style",
75
+ },
76
+ "
77
+ ",
78
+ {
79
+ "props": {
80
+ "crossorigin": "",
81
+ "data-wdio-ref": "8",
82
+ "src": "/__vitest_browser__/index-7107c1a2.js",
83
+ "type": "module",
84
+ },
85
+ "type": "script",
86
+ },
87
+ "
88
+ ",
89
+ ],
90
+ "data-wdio-ref": "2",
91
+ },
92
+ "type": "head",
93
+ },
94
+ "
95
+ ",
96
+ {
97
+ "props": {
98
+ "children": [
99
+ "
100
+ ",
101
+ {
102
+ "props": {
103
+ "data-wdio-ref": "10",
104
+ "id": "vitest-ui",
105
+ "src": "/__vitest__/",
106
+ },
107
+ "type": "iframe",
108
+ },
109
+ "
110
+ ",
111
+ {
112
+ "props": {
113
+ "children": "
114
+ const moduleCache = new Map()
115
+
116
+ // this method receives a module object or \\"import\\" promise that it resolves and keeps track of
117
+ // and returns a hijacked module object that can be used to mock module exports
118
+ function wrapModule(module) {
119
+ if (module instanceof Promise) {
120
+ moduleCache.set(module, { promise: module, evaluated: false })
121
+ return module
122
+ // TODO: add a test
123
+ .then(m => '__vi_inject__' in m ? m.__vi_inject__ : m)
124
+ .finally(() => moduleCache.delete(module))
125
+ }
126
+ return '__vi_inject__' in module ? module.__vi_inject__ : module
127
+ }
128
+
129
+ function exportAll(exports, sourceModule) {
130
+ // #1120 when a module exports itself it causes
131
+ // call stack error
132
+ if (exports === sourceModule)
133
+ return
134
+
135
+ if (Object(sourceModule) !== sourceModule || Array.isArray(sourceModule))
136
+ return
137
+
138
+ for (const key in sourceModule) {
139
+ if (key !== 'default') {
140
+ try {
141
+ Object.defineProperty(exports, key, {
142
+ enumerable: true,
143
+ configurable: true,
144
+ get: () => sourceModule[key],
145
+ })
146
+ }
147
+ catch (_err) { }
148
+ }
149
+ }
150
+ }
151
+
152
+ window.__vi_export_all__ = exportAll
153
+
154
+ // TODO: allow easier rewriting of import.meta.env
155
+ window.__vi_import_meta__ = {
156
+ env: {},
157
+ url: location.href,
158
+ }
159
+
160
+ window.__vi_module_cache__ = moduleCache
161
+ window.__vi_wrap_module__ = wrapModule
162
+ ",
163
+ "data-wdio-ref": "11",
164
+ },
165
+ "type": "script",
166
+ },
167
+ "
168
+
169
+
170
+
171
+ ",
172
+ ],
173
+ "data-wdio-ref": "9",
174
+ },
175
+ "type": "body",
176
+ },
177
+ ],
178
+ "data-wdio-ref": "1",
179
+ "lang": "en",
180
+ },
181
+ "type": "html",
182
+ },
183
+ ],
184
+ "removedNodes": [],
185
+ "type": "childList",
186
+ },
187
+ ]
188
+ `;
189
+
190
+ exports[`should be able to properly serialize changes 1`] = `"<div id=\\"change\\" data-wdio-ref=\\"12\\">some <i data-wdio-ref=\\"13\\">real</i> change</div>"`;
@@ -0,0 +1,47 @@
1
+ // import { test, expect } from 'vitest'
2
+ // import { h, render } from 'preact'
3
+ // import type { VNode as PreactVNode } from 'preact'
4
+
5
+ // function transform (node: SimplifiedVNode | string): PreactVNode<{}> | string {
6
+ // if (typeof node !== 'object') {
7
+ // return node
8
+ // }
9
+
10
+ // const { children, ...props } = node.props
11
+ // const childrenRequired = children || []
12
+ // const c = Array.isArray(childrenRequired) ? childrenRequired : [childrenRequired]
13
+ // return h(node.type as string, props, ...c.map(transform)) as PreactVNode<{}>
14
+ // }
15
+
16
+ // test('should be able serialize DOM', async () => {
17
+ // await import('../src/index.ts')
18
+ // expect(window.wdioCaptureErrors).toEqual([])
19
+ // expect(window.wdioDOMChanges.length).toBe(1)
20
+ // expect(window.wdioDOMChanges).toMatchSnapshot()
21
+ // })
22
+
23
+ // test('should be able to parse serialized DOM and render it', () => {
24
+ // const stage = document.createDocumentFragment()
25
+ // const [initial] = window.wdioDOMChanges
26
+ // render(transform(initial.addedNodes[0]), stage)
27
+ // expect(document.documentElement.outerHTML)
28
+ // .toBe((stage.childNodes[0] as HTMLElement).outerHTML)
29
+ // })
30
+
31
+ // test('should be able to properly serialize changes', async () => {
32
+ // const change = document.createElement('div')
33
+ // change.setAttribute('id', 'change')
34
+ // change.appendChild(document.createTextNode('some '))
35
+ // const bold = document.createElement('i')
36
+ // bold.appendChild(document.createTextNode('real'))
37
+ // change.appendChild(bold)
38
+ // change.appendChild(document.createTextNode(' change'))
39
+ // document.body.appendChild(change)
40
+
41
+ // await new Promise((resolve) => setTimeout(resolve, 10))
42
+ // expect(window.wdioDOMChanges.length).toBe(2)
43
+ // const [, vChange] = window.wdioDOMChanges
44
+ // const stage = document.createDocumentFragment()
45
+ // render(transform((vChange.addedNodes[0] as SimplifiedVNode).props.children as SimplifiedVNode), stage)
46
+ // expect((stage.childNodes[0] as HTMLElement).outerHTML).toMatchSnapshot()
47
+ // })
package/tsconfig.json ADDED
@@ -0,0 +1,7 @@
1
+ {
2
+ "extends": "../../tsconfig.json",
3
+ "compilerOptions": {
4
+ "moduleResolution": "Node16",
5
+ "module": "NodeNext"
6
+ }
7
+ }
package/types.d.ts ADDED
@@ -0,0 +1,40 @@
1
+ import type { DataCollectorType } from './src/collector.ts'
2
+ import type { ConsoleLog as ConsoleLogImport } from './src/collectors/consoleLogs.ts'
3
+
4
+ export interface TraceMetadata {
5
+ url: string
6
+ viewport: VisualViewport
7
+ }
8
+
9
+ export interface SimplifiedVNode {
10
+ type: string
11
+ props: Record<string, string> & { children?: SimplifiedVNode | SimplifiedVNode[] }
12
+ }
13
+
14
+ declare global {
15
+ type ConsoleLogs = ConsoleLogImport
16
+
17
+ interface Element {
18
+ 'wdio-ref': string
19
+ }
20
+
21
+ interface Window {
22
+ wdioTraceCollector: DataCollectorType
23
+ }
24
+
25
+ interface TraceMutation {
26
+ type: MutationRecordType
27
+ attributeName?: string
28
+ attributeNamespace?: string
29
+ attributeValue?: string
30
+ newTextContent?: string
31
+ oldValue?: string
32
+ addedNodes: (string | SimplifiedVNode)[]
33
+ target?: string
34
+ removedNodes: string[]
35
+ previousSibling?: string
36
+ nextSibling?: string
37
+ timestamp: number
38
+ url?: string
39
+ }
40
+ }
package/vite.config.ts ADDED
@@ -0,0 +1,15 @@
1
+ import { defineConfig } from 'vite'
2
+ import { viteSingleFile } from 'vite-plugin-singlefile'
3
+
4
+ // https://vitejs.dev/config/
5
+ export default defineConfig({
6
+ plugins: [viteSingleFile()],
7
+ build: {
8
+ lib: {
9
+ entry: 'src/index.ts',
10
+ formats: ['es'],
11
+ fileName: 'script'
12
+ },
13
+ target: 'esnext'
14
+ }
15
+ })