-
Notifications
You must be signed in to change notification settings - Fork 0
/
Copy pathrunner.js
281 lines (222 loc) · 9.02 KB
/
runner.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
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
const { createCoverageMap } = require('istanbul-lib-coverage');
const { createContext } = require('istanbul-lib-report');
const { create: createReporter } = require('istanbul-reports');
const { chromium } = require('playwright');
const { spawn } = require('child_process');
const os = require('os');
const path = require('path');
const pkg = require('../../package.json');
const assert = require('assert/strict');
const v8ToIstanbul = require('v8-to-istanbul');
// TODO:
// - should run return the current set of states back to the caller, so that they can be fed into another run?
// i.e. should run be re-entrant, so that runs can be composed together?
// - think about how best to annotate a run with a description of the test, maybe something like:
// await test('adds and deletes some employees', run('addEmployee', 'addEmployee', 'addEmployee', 'deleteEmployee'))
// - group transitions into an array:
// const addThreeEmployees = Array(3).fill('addEmployee');
// run('goHome', addThreeEmployees, ...)
// - format exceptions thrown from the transition and state functions
// - command line interface, that lets the user specify runs on the command line, host and port number for the target,
// request a textual representation of the NFA graph, with state tags and actions, and so on
// - use assertions instead of throwing errors
// - timings and logging
// - API for loading data from external sources prior to the test run (so you can select elements from the UI based on data in the UI's environment)
const isArray = Array.isArray;
const isString = s => typeof s === 'string';
const isFunction = f => typeof f === 'function';
const isObject = o => String(o) === '[object Object]';
const asyncReduce = (arr, fn, init) =>
arr.reduce((promise, x) => promise.then(memo => fn(memo, x)), Promise.resolve(init));
const asyncForEach = (arr, fn) => asyncReduce(arr, (_, x) => fn(x));
let uid = 0;
const state = (stateFn, optionalTag = '') => ({
id: uid++,
fn: stateFn,
actions: {},
epsilons: [],
tag: optionalTag
});
const start = state(() => {});
const action = (state, action, nextState, actionFn) => {
assert(state, 'state is required');
const addAction = (action, nextState, actionFn) => {
assert(action, 'action is required');
assert(!state.actions[action], `action ${action} already already defined for state ${state.tag}`);
state.actions[action] = [actionFn, nextState];
};
return action ? addAction(action, nextState, actionFn) : addAction;
};
const actions = (state, ...actionSpecs) =>
actionSpecs.forEach(spec => {
if (isObject(spec[0])) {
state.epsilons.push(...spec);
return;
} else if (isFunction(spec[0])) {
action(state, spec[0].name, spec[1], spec[0]);
return;
}
action(state, ...spec);
});
const startCoverage = async page =>
await page.coverage.startJSCoverage();
const stopCoverage = async (coverageMap, page) => {
const coverage = await page.coverage.stopJSCoverage();
await Promise.all(coverage.map(async entry => {
const convertor = v8ToIstanbul('', 0, { source: entry.source });
await convertor.load();
convertor.applyCoverage(entry.functions);
coverageMap.merge(convertor.toIstanbul());
}));
};
const reportCoverage = coverageMap => {
coverageMap.filter(file => !/node_modules|webpack|\?/.test(file));
const reportContext = createContext({ coverageMap });
['json', 'text', 'html'].forEach(reporter => {
createReporter(reporter).execute(reportContext);
});
};
const close = (...states) => {
states = states.flat();
const root = [], epsilons = [];
const rootSet = new Set(states.map(s => s.id));
const visited = new Set();
const stack = [...states];
let state;
while (stack.length) {
state = stack.pop();
if (visited.has(state.id)) continue;
visited.add(state.id);
// root set states will have priority
if (rootSet.has(state.id)) root.push(state);
else epsilons.push(state);
stack.push(...state.epsilons);
}
return [root, epsilons];
};
const checkPath = (states, actions = []) => {
states = close(states).flat();
return actions.reduce((reached, action) => {
states = close(states.reduce((nextStates, state) => {
if (!state.actions[action]) return nextStates;
nextStates.push(state.actions[action][1]);
return nextStates;
}, [])).flat();
if (states.length) reached.push(action);
return reached;
}, []);
};
const defaultExecPath = '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome';
const defaultArgs = [
'--remote-debugging-port=0',
'--no-startup-window',
'--enable-automation',
'--no-first-run'
];
const defaultOptions = {
detached: true,
stdio: ['ignore', 'pipe', 'pipe']
};
const DETACH_TIMEOUT = 3000;
class DevToolsTimeoutError extends Error {
constructor(timeout) {
super(`Process launched, but no devtools server could be found within ${timeout} milliseconds`);
this.name = 'DevToolsTimeoutError';
}
}
const terminate = proc => proc.kill() || proc.kill('SIGKILL');
const spawnDetached = (args = defaultArgs, options = defaultOptions, execPath = defaultExecPath) => new Promise((resolve, reject) => {
args = args.concat(`--user-data-dir=${path.join(os.tmpdir(), `${pkg.name}-chromedata`)}`);
const chrome = spawn(execPath, args, options);
chrome.unref();
const timeoutId = setTimeout(() => {
terminate(chrome);
reject(new DevToolsTimeoutError(DETACH_TIMEOUT));
}, DETACH_TIMEOUT);
chrome.stderr.on('data', data => {
const m = String(data).match(/devtools listening on (ws:\/\/\S+)/i);
if (m) {
clearTimeout(timeoutId);
resolve(m[1]);
}
});
chrome.on('error', err => {
clearTimeout(timeoutId);
reject(err);
});
});
const launch = async (browserOpts, launchFn) => {
const detached = process.env.DETACHED !== undefined;
let browser;
if (detached) {
browser = await chromium.connectOverCDP({ endpointURL: await spawnDetached(), timeout: 2000 });
} else {
browser = await chromium.launch(browserOpts);
}
const coverageMap = createCoverageMap();
let uid = 0;
const lines = [];
const makeLog = () => {
const id = uid++;
lines[id] = '';
// TODO: figure out a way to allow the user to continue console.logging,
// but also prepend these to the output
return str => {
lines[id] += str;
console.clear();
lines.forEach(li => console.log(li));
};
};
const run = async (pageOpts, ...actions) => {
actions = actions.flat();
if (isString(pageOpts)) pageOpts = [pageOpts];
if (isArray(pageOpts)) actions = pageOpts.concat(actions);
const reached = checkPath(start, actions);
assert(reached.length === actions.length,
`Action route not found.\nReached: ${JSON.stringify(reached)}\nProvided: ${JSON.stringify(actions)}`);
const writeToLine = makeLog();
const page = await browser.newPage(isObject(pageOpts) ? pageOpts : undefined);
const fixtures = Object.freeze({ browser, page, context: {} });
writeToLine('| ');
await startCoverage(page);
let first = true;
let [root, epsilon] = close(start);
await asyncForEach(actions, async action => {
if (!first) writeToLine(' > ');
writeToLine(action);
first = false;
const tryState = async (nextStates, state) => {
if (!state.actions[action]) return nextStates;
const [actionFn, nextState] = state.actions[action];
await actionFn(fixtures);
// eagerly execute the next state function
// this should be fine, because we're explicitly giving root states
// precedence over epsilon states, and the number of transitions should
// then be fairly limited for each action
await nextState.fn(fixtures);
nextStates.push(nextState);
return nextStates;
};
// scan the root set for viable transitions on the action (and make any that are found)
root = await asyncReduce(root, tryState, []);
// we found transitions in the root set, no need to scan the epsilons
if (root.length) {
[root, epsilon] = close(root);
return;
}
// no transitions found, scan the epsilons
[root, epsilon] = close(await asyncReduce(epsilon, tryState, []));
});
await stopCoverage(coverageMap, page);
};
const runAll = (...runs) => Promise.all(runs.flat());
await launchFn({ browser, run, runAll });
reportCoverage(coverageMap);
if (!detached) await browser.close();
};
module.exports = {
start,
state,
actions,
launch
};