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