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 | runScripts,
|
110 | setElementsToScroll,
|
111 | scrollToBottomOfElement,
|
112 | scrollToTopOfElement,
|
113 | stickElementToTop,
|
114 | unstickElementFromTop,
|
115 | window,
|
116 | response: resp
|
117 | };
|
118 |
|
119 | Object.defineProperty(browserContext, "_pending", {
|
120 | get: () => pending
|
121 | });
|
122 |
|
123 | currentPageXOffset = window.pageXOffset;
|
124 | currentPageYOffset = window.pageYOffset;
|
125 |
|
126 | document.addEventListener("submit", onDocumentSubmit);
|
127 | window.addEventListener("scroll", onWindowScroll);
|
128 |
|
129 | focus();
|
130 |
|
131 | return browserContext;
|
132 |
|
133 | function focus() {
|
134 | }
|
135 |
|
136 | function onDocumentSubmit(event) {
|
137 | if (event.target.tagName === "FORM") {
|
138 | pending = new Promise((resolve) => {
|
139 | process.nextTick(navigate, resolve);
|
140 | });
|
141 | }
|
142 |
|
143 | function navigate(resolve) {
|
144 | if (event.defaultPrevented) return resolve();
|
145 |
|
146 | const form = event.target;
|
147 | const method = form.getAttribute("method") || "GET";
|
148 | const formaction = (event._submitElement && event._submitElement.getAttribute("formaction")) || form.getAttribute("action");
|
149 | const action = formaction || window.location.pathname + (window.location.search ? window.location.search : "");
|
150 | const submitHeaders = {...options.headers, ...headers, cookie: agent.jar.getCookies({path: action}).toValueString()};
|
151 |
|
152 | const formData = getFormData(form, event._submitElement);
|
153 |
|
154 | if (method.toUpperCase() === "GET") {
|
155 | const p = url.parse(action, true);
|
156 | Object.assign(p.query, formData);
|
157 |
|
158 | const navigation = navigateTo(url.format(p), submitHeaders);
|
159 | resolve(navigation);
|
160 | } else if (method.toUpperCase() === "POST") {
|
161 | if (action.startsWith("/") || url.parse(action).host === submitHeaders.host) {
|
162 | agent.post(url.parse(action).path)
|
163 | .set(submitHeaders)
|
164 | .set("Content-Type", "application/x-www-form-urlencoded")
|
165 | .send(querystring.stringify(formData))
|
166 | .then((postResp) => {
|
167 | if ([301, 302].includes(postResp.statusCode)) return navigateTo(postResp.headers.location);
|
168 | return load(postResp);
|
169 | })
|
170 | .then(resolve);
|
171 | } else {
|
172 | Request.post(action, {
|
173 | headers: {
|
174 | "Content-Type": "application/x-www-form-urlencoded"
|
175 | },
|
176 | body: querystring.stringify(formData)
|
177 | }, (err, res) => {
|
178 | if (err) {
|
179 | throw err;
|
180 | }
|
181 | resolve(load(res));
|
182 | });
|
183 | }
|
184 | }
|
185 | }
|
186 | }
|
187 |
|
188 | function runScripts(context) {
|
189 | context = context || document.documentElement;
|
190 | context.$elm.find("script").each((idx, elm) => {
|
191 | const $script = document.$(elm);
|
192 | const scriptType = $script.attr("type");
|
193 | if (scriptType && !/javascript/i.test(scriptType)) return;
|
194 |
|
195 | const scriptBody = $script.html();
|
196 | if (scriptBody) vm.runInNewContext(scriptBody, window);
|
197 | });
|
198 | }
|
199 |
|
200 | function setElementsToScroll(elmsToScrollFn) {
|
201 | elementsToScroll = elmsToScrollFn;
|
202 | }
|
203 |
|
204 | function onWindowScroll() {
|
205 | if (!elementsToScroll) return;
|
206 | const elms = elementsToScroll(document);
|
207 | if (!elms || !elms.length) return;
|
208 |
|
209 | const {pageXOffset, pageYOffset} = window;
|
210 | const deltaX = currentPageXOffset - pageXOffset;
|
211 | const deltaY = currentPageYOffset - pageYOffset;
|
212 |
|
213 | elms.slice().forEach((elm) => {
|
214 | if (isElementSticky(elm)) return;
|
215 |
|
216 | const {left, right, top, bottom} = elm.getBoundingClientRect();
|
217 | elm._setBoundingClientRect({
|
218 | left: (left || 0) + deltaX,
|
219 | right: (right || 0) + deltaX,
|
220 | top: (top || 0) + deltaY,
|
221 | bottom: (bottom || 0) + deltaY,
|
222 | });
|
223 | });
|
224 |
|
225 | currentPageXOffset = pageXOffset;
|
226 | currentPageYOffset = pageYOffset;
|
227 | }
|
228 |
|
229 | function scrollToTopOfElement(element, offset = 0) {
|
230 | if (isElementSticky(element)) throw new Error("Cannot scroll to sticky element");
|
231 |
|
232 | const {top} = element.getBoundingClientRect();
|
233 |
|
234 | const pageYOffset = window.pageYOffset;
|
235 | let newYOffset = pageYOffset + top - offset;
|
236 | if (newYOffset < 0) newYOffset = 0;
|
237 |
|
238 | window.scroll(window.pageXOffset, newYOffset);
|
239 | }
|
240 |
|
241 | function scrollToBottomOfElement(element, offset = 0) {
|
242 | if (isElementSticky(element)) throw new Error("Cannot scroll to sticky element");
|
243 | const {height} = element.getBoundingClientRect();
|
244 | const offsetFromBottom = window.innerHeight - height;
|
245 | return scrollToTopOfElement(element, offsetFromBottom + offset);
|
246 | }
|
247 |
|
248 | function stickElementToTop(element) {
|
249 | if (isElementSticky(element)) return;
|
250 |
|
251 | const {top, height} = element.getBoundingClientRect();
|
252 | element._tallahasseePositionBeforeSticky = window.pageYOffset + top;
|
253 | element._setBoundingClientRect({
|
254 | top: 0,
|
255 | bottom: (height || 0)
|
256 | });
|
257 | stickedElements.push(element);
|
258 | }
|
259 |
|
260 | function unstickElementFromTop(element) {
|
261 | const idx = stickedElements.indexOf(element);
|
262 | if (idx < 0) return;
|
263 | stickedElements.splice(idx, 1);
|
264 | const top = element._tallahasseePositionBeforeSticky - window.pageYOffset;
|
265 | const {height} = element.getBoundingClientRect();
|
266 | element._setBoundingClientRect({
|
267 | top: top,
|
268 | bottom: height ? top + height : top
|
269 | });
|
270 | element._tallahasseePositionBeforeSticky = undefined;
|
271 | }
|
272 |
|
273 | function isElementSticky(element) {
|
274 | return stickedElements.indexOf(element) > -1;
|
275 | }
|
276 |
|
277 | async function focusIframe(element, src) {
|
278 | if (!element) return;
|
279 | if (!element.tagName === "IFRAME") return;
|
280 |
|
281 | src = src || element.src;
|
282 |
|
283 | const srcUrl = makeAbsolute(browserContext.window.location, src);
|
284 | const parsedUrl = url.parse(srcUrl);
|
285 |
|
286 | if (parsedUrl.host !== browserContext.window.location.host) return requestExternalContent(srcUrl);
|
287 |
|
288 | const iframeScope = await navigateTo(parsedUrl.path, headers);
|
289 | iframeScope.window.frameElement = element;
|
290 | iframeScope.window.top = browserContext.window;
|
291 |
|
292 | return iframeScope;
|
293 |
|
294 | function requestExternalContent(externalUrl) {
|
295 | const prom = new Promise((resolve, reject) => {
|
296 | Request.get(externalUrl, (err, getResp) => {
|
297 | if (err) return reject(err);
|
298 |
|
299 | const {request, body: text} = getResp;
|
300 | const {href} = request;
|
301 | request.url = href;
|
302 |
|
303 | resolve({request, text});
|
304 | });
|
305 | });
|
306 |
|
307 | return prom.then((scopeResp) => {
|
308 | return Tallahassee(app).load(scopeResp);
|
309 | }).then((scopedBrowser) => {
|
310 | scopedBrowser.window.top = getLockedWindow(scopedBrowser.window.location.href);
|
311 | return scopedBrowser;
|
312 | });
|
313 | }
|
314 | }
|
315 |
|
316 | function getLockedWindow(frameSrc) {
|
317 | const lockedWindow = {};
|
318 | const location = {};
|
319 |
|
320 | const origin = url.parse(frameSrc);
|
321 |
|
322 | lockedWindow.location = new Proxy(location, {
|
323 | set: unauth,
|
324 | get: unauth,
|
325 | deleteProperty: unauth
|
326 | });
|
327 |
|
328 | return lockedWindow;
|
329 |
|
330 | function unauth() {
|
331 | throw new Error(`Blocked a frame with origin "${origin.protocol}//${origin.host}" from accessing a cross-origin frame.`);
|
332 | }
|
333 | }
|
334 | }
|
335 | }
|
336 |
|
337 | function getFormData(form, submitElement) {
|
338 | const inputs = form.getElementsByTagName("input");
|
339 |
|
340 | const payload = inputs.reduce((acc, input) => {
|
341 | if (input.disabled) return acc;
|
342 |
|
343 | if (input.name && input.value) {
|
344 | if (input.type === "radio" || input.type === "checkbox") {
|
345 | if (input.checked) {
|
346 | acc[input.name] = acc[input.name] || [];
|
347 | acc[input.name].push(input.value);
|
348 | }
|
349 | } else {
|
350 | acc[input.name] = input.value;
|
351 | }
|
352 | }
|
353 | return acc;
|
354 | }, {});
|
355 |
|
356 | if (submitElement && submitElement.name) {
|
357 | payload[submitElement.name] = submitElement.value;
|
358 | }
|
359 |
|
360 | return payload;
|
361 | }
|