1 | "use strict";
|
2 |
|
3 | const querystring = require("querystring");
|
4 | const getHeaders = require("./lib/getHeaders");
|
5 | const makeAbsolute = require("./lib/makeAbsolute");
|
6 | const Request = require("request");
|
7 | const supertest = require("supertest");
|
8 | const url = require("url");
|
9 | const vm = require("vm");
|
10 | const {Document, Fetch, Window} = require("./lib");
|
11 | const assert = require("assert");
|
12 |
|
13 | module.exports = Tallahassee;
|
14 |
|
15 | function Tallahassee(app, options = {}) {
|
16 | const agent = supertest.agent(app);
|
17 | return {
|
18 | navigateTo,
|
19 | load,
|
20 | };
|
21 |
|
22 | function navigateTo(linkUrl, headers = {}, statusCode = 200) {
|
23 | if (options.headers) {
|
24 | headers = {
|
25 | ...options.headers,
|
26 | ...headers,
|
27 | };
|
28 | }
|
29 |
|
30 | let numRedirects = 0;
|
31 | for (const key in headers) {
|
32 | if (key.toLowerCase() === "cookie") {
|
33 | agent.jar.setCookies(headers[key].split(";").map((c) => c.trim()).filter(Boolean));
|
34 | }
|
35 | }
|
36 |
|
37 | return makeRequest(linkUrl)
|
38 | .then((resp) => {
|
39 | assert.equal(resp.statusCode, statusCode, `Unexepected status code. Expected: ${statusCode}. Actual: ${resp.statusCode}`);
|
40 | assert(resp.headers["content-type"].match(/text\/html/i), `Unexepected content type. Expected: text/html. Actual: ${resp.headers["content-type"]}`);
|
41 | return resp;
|
42 | })
|
43 | .then(load);
|
44 |
|
45 | function makeRequest(reqUrl) {
|
46 | let request;
|
47 | const parsedUrl = url.parse(reqUrl);
|
48 |
|
49 | if (parsedUrl.host && parsedUrl.host !== headers.host) {
|
50 | request = new Promise((resolve, reject) => {
|
51 | Request.get(reqUrl, { followRedirect: false }, (externalReqErr, externalReqRes) => {
|
52 | if (externalReqErr) {
|
53 | return reject(externalReqErr);
|
54 | }
|
55 | return resolve(externalReqRes);
|
56 | });
|
57 | });
|
58 | } else {
|
59 | if (parsedUrl.host) {
|
60 | reqUrl = reqUrl.replace(`${parsedUrl.protocol}//${parsedUrl.host}`, "");
|
61 | }
|
62 | request = agent.get(reqUrl).redirects(0);
|
63 | for (const key in headers) {
|
64 | if (key.toLowerCase() !== "cookie") {
|
65 | request.set(key, headers[key]);
|
66 | }
|
67 | }
|
68 | }
|
69 |
|
70 | return request.then((res) => {
|
71 | if (res.statusCode > 300 && res.statusCode < 308) {
|
72 | numRedirects++;
|
73 | if (numRedirects > 20) {
|
74 | throw new Error("Too many redirects");
|
75 | }
|
76 | return makeRequest(res.headers.location);
|
77 | }
|
78 | return res;
|
79 | });
|
80 | }
|
81 | }
|
82 |
|
83 | function load(resp) {
|
84 | let pending, currentPageXOffset, currentPageYOffset;
|
85 | let elementsToScroll = () => {};
|
86 | const stickedElements = [];
|
87 |
|
88 | const headers = getHeaders(resp.request);
|
89 | const document = Document(resp, agent.jar);
|
90 | const window = Window(resp, {
|
91 | fetch: Fetch(agent, resp),
|
92 | get document() {
|
93 | return document;
|
94 | },
|
95 | });
|
96 |
|
97 | Object.defineProperty(document, "window", {
|
98 | get() {
|
99 | return window;
|
100 | }
|
101 | });
|
102 |
|
103 | const browserContext = {
|
104 | $: document.$,
|
105 | document,
|
106 | focus,
|
107 | focusIframe,
|
108 | navigateTo,
|
109 | runScript,
|
110 | runScripts,
|
111 | setElementsToScroll,
|
112 | scrollToBottomOfElement,
|
113 | scrollToTopOfElement,
|
114 | stickElementToTop,
|
115 | unstickElementFromTop,
|
116 | window,
|
117 | response: resp
|
118 | };
|
119 |
|
120 | Object.defineProperty(browserContext, "_pending", {
|
121 | get: () => pending
|
122 | });
|
123 |
|
124 | currentPageXOffset = window.pageXOffset;
|
125 | currentPageYOffset = window.pageYOffset;
|
126 |
|
127 | document.addEventListener("submit", onDocumentSubmit);
|
128 | window.addEventListener("scroll", onWindowScroll);
|
129 |
|
130 | focus();
|
131 |
|
132 | return browserContext;
|
133 |
|
134 | function focus() {
|
135 | }
|
136 |
|
137 | function onDocumentSubmit(event) {
|
138 | if (event.target.tagName === "FORM") {
|
139 | pending = new Promise((resolve) => {
|
140 | process.nextTick(navigate, resolve);
|
141 | });
|
142 | }
|
143 |
|
144 | function navigate(resolve) {
|
145 | if (event.defaultPrevented) return resolve();
|
146 |
|
147 | const form = event.target;
|
148 | const method = form.getAttribute("method") || "GET";
|
149 | const formaction = (event._submitElement && event._submitElement.getAttribute("formaction")) || form.getAttribute("action");
|
150 | const action = formaction || window.location.pathname + (window.location.search ? window.location.search : "");
|
151 | const submitHeaders = {...options.headers, ...headers, cookie: agent.jar.getCookies({path: action}).toValueString()};
|
152 |
|
153 | const formData = getFormData(form, event._submitElement);
|
154 |
|
155 | if (method.toUpperCase() === "GET") {
|
156 | const p = url.parse(action, true);
|
157 | Object.assign(p.query, formData);
|
158 |
|
159 | const navigation = navigateTo(url.format(p), submitHeaders);
|
160 | resolve(navigation);
|
161 | } else if (method.toUpperCase() === "POST") {
|
162 | if (action.startsWith("/") || url.parse(action).host === submitHeaders.host) {
|
163 | agent.post(url.parse(action).path)
|
164 | .set(submitHeaders)
|
165 | .set("Content-Type", "application/x-www-form-urlencoded")
|
166 | .send(querystring.stringify(formData))
|
167 | .then((postResp) => {
|
168 | if ([301, 302].includes(postResp.statusCode)) return navigateTo(postResp.headers.location);
|
169 | return load(postResp);
|
170 | })
|
171 | .then(resolve);
|
172 | } else {
|
173 |
|
174 | Request.post(action, {
|
175 | headers: {
|
176 | "Content-Type": "application/x-www-form-urlencoded"
|
177 | },
|
178 | body: querystring.stringify(formData)
|
179 | }, (err, res) => {
|
180 | if (err) {
|
181 | throw err;
|
182 | }
|
183 | resolve(load(res));
|
184 | });
|
185 | }
|
186 | }
|
187 | }
|
188 | }
|
189 |
|
190 | function runScript(script) {
|
191 | const $script = script.$elm;
|
192 |
|
193 | const scriptType = $script.attr("type");
|
194 | if (scriptType && !/javascript/i.test(scriptType)) return;
|
195 |
|
196 | const scriptBody = $script.html();
|
197 | if (scriptBody) vm.runInNewContext(scriptBody, window);
|
198 | }
|
199 |
|
200 | function runScripts(context) {
|
201 | context = context || document.documentElement;
|
202 |
|
203 | context.$elm.find("script").each((idx, elm) => {
|
204 | const script = document._getElement(document.$(elm));
|
205 |
|
206 | runScript(script);
|
207 | });
|
208 | }
|
209 |
|
210 | function setElementsToScroll(elmsToScrollFn) {
|
211 | elementsToScroll = elmsToScrollFn;
|
212 | }
|
213 |
|
214 | function onWindowScroll() {
|
215 | if (!elementsToScroll) return;
|
216 | const elms = elementsToScroll(document);
|
217 | if (!elms || !elms.length) return;
|
218 |
|
219 | const {pageXOffset, pageYOffset} = window;
|
220 | const deltaX = currentPageXOffset - pageXOffset;
|
221 | const deltaY = currentPageYOffset - pageYOffset;
|
222 |
|
223 | elms.slice().forEach((elm) => {
|
224 | if (isElementSticky(elm)) return;
|
225 |
|
226 | const {left, right, top, bottom} = elm.getBoundingClientRect();
|
227 | elm._setBoundingClientRect({
|
228 | left: (left || 0) + deltaX,
|
229 | right: (right || 0) + deltaX,
|
230 | top: (top || 0) + deltaY,
|
231 | bottom: (bottom || 0) + deltaY,
|
232 | });
|
233 | });
|
234 |
|
235 | currentPageXOffset = pageXOffset;
|
236 | currentPageYOffset = pageYOffset;
|
237 | }
|
238 |
|
239 | function scrollToTopOfElement(element, offset = 0) {
|
240 | if (isElementSticky(element)) throw new Error("Cannot scroll to sticky element");
|
241 |
|
242 | const {top} = element.getBoundingClientRect();
|
243 |
|
244 | const pageYOffset = window.pageYOffset;
|
245 | let newYOffset = pageYOffset + top - offset;
|
246 | if (newYOffset < 0) newYOffset = 0;
|
247 |
|
248 | window.scroll(window.pageXOffset, newYOffset);
|
249 | }
|
250 |
|
251 | function scrollToBottomOfElement(element, offset = 0) {
|
252 | if (isElementSticky(element)) throw new Error("Cannot scroll to sticky element");
|
253 | const {height} = element.getBoundingClientRect();
|
254 | const offsetFromBottom = window.innerHeight - height;
|
255 | return scrollToTopOfElement(element, offsetFromBottom + offset);
|
256 | }
|
257 |
|
258 | function stickElementToTop(element) {
|
259 | if (isElementSticky(element)) return;
|
260 |
|
261 | const {top, height} = element.getBoundingClientRect();
|
262 | element._tallahasseePositionBeforeSticky = window.pageYOffset + top;
|
263 | element._setBoundingClientRect({
|
264 | top: 0,
|
265 | bottom: (height || 0)
|
266 | });
|
267 | stickedElements.push(element);
|
268 | }
|
269 |
|
270 | function unstickElementFromTop(element) {
|
271 | const idx = stickedElements.indexOf(element);
|
272 | if (idx < 0) return;
|
273 | stickedElements.splice(idx, 1);
|
274 | const top = element._tallahasseePositionBeforeSticky - window.pageYOffset;
|
275 | const {height} = element.getBoundingClientRect();
|
276 | element._setBoundingClientRect({
|
277 | top: top,
|
278 | bottom: height ? top + height : top
|
279 | });
|
280 | element._tallahasseePositionBeforeSticky = undefined;
|
281 | }
|
282 |
|
283 | function isElementSticky(element) {
|
284 | return stickedElements.indexOf(element) > -1;
|
285 | }
|
286 |
|
287 | async function focusIframe(element, src) {
|
288 | if (!element) return;
|
289 | if (!element.tagName === "IFRAME") return;
|
290 |
|
291 | src = src || element.src;
|
292 |
|
293 | const srcUrl = makeAbsolute(browserContext.window.location, src);
|
294 | const parsedUrl = url.parse(srcUrl);
|
295 |
|
296 | if (parsedUrl.host !== browserContext.window.location.host) return requestExternalContent(srcUrl);
|
297 |
|
298 | const iframeScope = await navigateTo(parsedUrl.path, headers);
|
299 | iframeScope.window.frameElement = element;
|
300 | iframeScope.window.top = browserContext.window;
|
301 |
|
302 | return iframeScope;
|
303 |
|
304 | function requestExternalContent(externalUrl) {
|
305 | const prom = new Promise((resolve, reject) => {
|
306 | Request.get(externalUrl, (err, getResp) => {
|
307 | if (err) return reject(err);
|
308 |
|
309 | const {request, body: text} = getResp;
|
310 | const {href} = request;
|
311 | request.url = href;
|
312 |
|
313 | resolve({request, text});
|
314 | });
|
315 | });
|
316 |
|
317 | return prom.then((scopeResp) => {
|
318 | return Tallahassee(app).load(scopeResp);
|
319 | }).then((scopedBrowser) => {
|
320 | scopedBrowser.window.top = getLockedWindow(scopedBrowser.window.location.href);
|
321 | return scopedBrowser;
|
322 | });
|
323 | }
|
324 | }
|
325 |
|
326 | function getLockedWindow(frameSrc) {
|
327 | const lockedWindow = {};
|
328 | const location = {};
|
329 |
|
330 | const origin = url.parse(frameSrc);
|
331 |
|
332 | lockedWindow.location = new Proxy(location, {
|
333 | set: unauth,
|
334 | get: unauth,
|
335 | deleteProperty: unauth
|
336 | });
|
337 |
|
338 | return lockedWindow;
|
339 |
|
340 | function unauth() {
|
341 | throw new Error(`Blocked a frame with origin "${origin.protocol}//${origin.host}" from accessing a cross-origin frame.`);
|
342 | }
|
343 | }
|
344 | }
|
345 | }
|
346 |
|
347 | function getFormData(form, submitElement) {
|
348 | const inputs = form.elements;
|
349 |
|
350 | const payload = inputs.reduce((acc, input) => {
|
351 | if (input.disabled) return acc;
|
352 |
|
353 | if (input.name && input.value) {
|
354 | if (input.type === "radio" || input.type === "checkbox") {
|
355 | if (input.checked) {
|
356 | acc[input.name] = acc[input.name] || [];
|
357 | acc[input.name].push(input.value);
|
358 | }
|
359 | } else if (input.tagName === "SELECT") {
|
360 | const selected = input.selectedOptions;
|
361 |
|
362 |
|
363 | if (selected.length === 1) {
|
364 | acc[input.name] = selected[0].value;
|
365 | } else if (selected.length > 1) {
|
366 | acc[input.name] = selected.map((option) => option.getAttribute("value"));
|
367 | }
|
368 | } else {
|
369 | acc[input.name] = input.value;
|
370 | }
|
371 | }
|
372 | return acc;
|
373 | }, {});
|
374 |
|
375 | if (submitElement && submitElement.name) {
|
376 | payload[submitElement.name] = submitElement.value;
|
377 | }
|
378 |
|
379 | return payload;
|
380 | }
|